<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Allen Jones</title>
    <description>The latest articles on DEV Community by Allen Jones (@allenarduino).</description>
    <link>https://dev.to/allenarduino</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F679555%2F89b03aae-198c-48b1-acb5-9637c4f5ef61.jpeg</url>
      <title>DEV Community: Allen Jones</title>
      <link>https://dev.to/allenarduino</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/allenarduino"/>
    <language>en</language>
    <item>
      <title>How to Create a Client Portal for Your Agency or Freelance Business Using Google Sheets</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:09:50 +0000</pubDate>
      <link>https://dev.to/allenarduino/how-to-create-a-client-portal-for-your-agency-or-freelance-business-using-google-sheets-4bc9</link>
      <guid>https://dev.to/allenarduino/how-to-create-a-client-portal-for-your-agency-or-freelance-business-using-google-sheets-4bc9</guid>
      <description>&lt;p&gt;Most agencies and freelancers share project updates the same way. They send an email.  The client replies asking for the invoice. &lt;br&gt;
Another email. The client asks where the latest design file is. Another email.&lt;/p&gt;

&lt;p&gt;By the end of a project, there are forty email threads, three different shared Google Drive folders, and a client who still does not know what stage the project is at.&lt;/p&gt;

&lt;p&gt;A client portal fixes this. Instead of scattered emails, your client gets one private link. They open it and see everything in one &lt;br&gt;
place. Project status, milestones, documents,  invoices, and recent activity. No login. No account to create. Just a clean, professional &lt;br&gt;
dashboard that makes you look like you built something custom for them.&lt;/p&gt;

&lt;p&gt;This tutorial shows you how to set one up &lt;br&gt;
using SheetRocket and a Google Sheet in under 30 minutes.&lt;/p&gt;


&lt;h2&gt;
  
  
  What You Will Build
&lt;/h2&gt;

&lt;p&gt;A private client portal with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dashboard overview with key project stats&lt;/li&gt;
&lt;li&gt;A project milestones tracker&lt;/li&gt;
&lt;li&gt;A documents section with download links&lt;/li&gt;
&lt;li&gt;An invoice history with payment status&lt;/li&gt;
&lt;li&gt;A recent activity feed&lt;/li&gt;
&lt;li&gt;A message form to contact you&lt;/li&gt;
&lt;li&gt;A private link that only your client can access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your client never sees a spreadsheet. They see &lt;br&gt;
a professional application-style dashboard. You &lt;br&gt;
manage everything from a Google Sheet you already know how to use.&lt;/p&gt;


&lt;h2&gt;
  
  
  What You Will Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A free Sheetrocket account&lt;/li&gt;
&lt;li&gt;A Google account&lt;/li&gt;
&lt;li&gt;30 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No code. No database. No authentication system to build.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why Google Sheets as the Backend
&lt;/h2&gt;

&lt;p&gt;The question most people ask first is why &lt;br&gt;
not just use Notion or a proper project &lt;br&gt;
management tool.&lt;/p&gt;

&lt;p&gt;The answer is control and simplicity. With a Google Sheet, you own the data completely. You can structure it exactly how your business works. You can share edit access with a team member. You can have multiple sheets for multiple clients, all managed from one place you already know.&lt;/p&gt;

&lt;p&gt;Most importantly, your client never needs to learn a new tool. They visit their private link and see their information. &lt;br&gt;
That is the entire client experience.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Copy the Client Portal
&lt;/h2&gt;

&lt;p&gt;Google Sheet Template&lt;/p&gt;

&lt;p&gt;Sheetrocket provides a ready-made Google &lt;br&gt;
Sheet template for the Agency Client Portal. &lt;br&gt;
It has five tabs with the right structure already set up.&lt;/p&gt;

&lt;p&gt;Go to &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;sheetrocket.com&lt;/a&gt; &lt;br&gt;
and sign up for free.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffeqr3j3lpe03wc0ovbgf.png" alt="Sheetrocket Landing Page" width="800" height="443"&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe451yruv8wyieb0k7894.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe451yruv8wyieb0k7894.png" alt="Screenshot of the Sheetrocket signup page" width="800" height="606"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Once you are in your dashboard, click &lt;br&gt;
&lt;strong&gt;Pages&lt;/strong&gt; in the left sidebar. Then click the &lt;strong&gt;Portals&lt;/strong&gt; tab at the top of the page.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;New portal&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhm1h99q4efsf1s5ua1j6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhm1h99q4efsf1s5ua1j6.png" alt="Screenshot of the Pages page with the Catalogues and Portals tabs visible, Portals tab selected" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will see a gallery of eight portal templates. Each one is designed for a specific business type.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7vycsv8c7eha658papt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7vycsv8c7eha658papt.png" alt="Screenshot of the portal template gallery showing Agency Client Portal, Freelancer Project Portal, Property Management Portal, Coaching Portal, and others" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The available templates are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agency Client Portal
Freelancer Project Portal
Property Management Portal
Coaching Client Portal
Accountant Client Portal
Legal Client Portal
Wedding Planner Portal
Personal Trainer Portal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click &lt;strong&gt;Agency Client Portal&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Name Your Portal
&lt;/h2&gt;

&lt;p&gt;You will be asked for two things: a portal name and an optional client name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Portal name:   Acme Corporation Portal
Client name:   Acme Corporation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The portal name is for your own reference &lt;br&gt;
in the Sheetrocket dashboard. The client name appears in the portal itself as a personalised greeting for your client.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwpo2id2wk9cdw9c31w2l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwpo2id2wk9cdw9c31w2l.png" alt="Screenshot of the portal naming step with Portal name and Client name fields filled in" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49uv69q41w65zklkkmso.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49uv69q41w65zklkkmso.png" alt="Screenshot of portal created confirmation modal" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will also see an offer to copy the Agency Client Portal Google Sheet template. Click &lt;strong&gt;Copy Google Sheet template&lt;/strong&gt;. This &lt;br&gt;
opens a pre-structured Google Sheet in your Google Drive with sample data already filled in across five tabs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hczicdsd27xe1363dl0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hczicdsd27xe1363dl0.png" alt="Screenshot of the starter sheet offer with Copy Google Sheet template button highlighted" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Create portal&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Your Portal Is Ready
&lt;/h2&gt;

&lt;p&gt;After creating the portal, you will see a &lt;br&gt;
success screen. This is the most important &lt;br&gt;
screen in the entire setup.&lt;/p&gt;

&lt;p&gt;SheetRocket has automatically generated a &lt;br&gt;
unique private link for your client. It &lt;br&gt;
looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;acmecorporation.sheetrocket.site?token=xK9mP2qR4nL7vB2w
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49uv69q41w65zklkkmso.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49uv69q41w65zklkkmso.png" alt="Screenshot of the success screen showing the private link prominently with a Copy client link button" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Copy this link and keep it safe. This is &lt;br&gt;
the only way your client can access their portal. Anyone who visits the URL without the token at the end sees an Access Denied &lt;br&gt;
page. Your client's data is private by default.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Open in editor&lt;/strong&gt; to continue &lt;br&gt;
setting up the portal.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 4: See What Your Client Will See
&lt;/h2&gt;

&lt;p&gt;The editor shows your portal exactly as your client will see it. It is pre-loaded with sample data so you can immediately see what the finished portal looks like.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpt7qz1gb6tp5196cvzr3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpt7qz1gb6tp5196cvzr3.png" alt="Screenshot of the portal editor showing the agency portal with sidebar navigation, welcome header, stats row, and preview sections" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The portal has a professional layout with:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sticky header&lt;/strong&gt; showing your agency &lt;br&gt;
name and the client's name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A left sidebar&lt;/strong&gt; with navigation links:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Overview
Project
Documents
Invoices
Activity
Messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An overview dashboard&lt;/strong&gt; showing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Four stat cards:
  Project progress percentage
  Next deadline date
  Balance due
  Milestones completed out of total

A project progress bar

Recent milestones preview

Recent documents preview

Recent invoices preview

Recent activity preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each preview section shows three items with a View all link. Clicking View all or any sidebar link takes your client to the full list for that section.&lt;/p&gt;

&lt;p&gt;This is the experience your client &lt;br&gt;
gets from a single Google Sheet.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 5: Fill In Your Client's
&lt;/h2&gt;

&lt;p&gt;Google Sheet&lt;/p&gt;

&lt;p&gt;Go to the Google Sheet you copied in &lt;br&gt;
Step 2. You will find five tabs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Projects
Milestones
Invoices
Documents
Updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the sample data with your &lt;br&gt;
real client's project information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Projects tab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client     | Project          | Status      | Progress | Amount  | Start Date  | Deadline    | Notes
Acme Corp  | Website Redesign | In Progress | 75%      | $2,400  | Aug 1 2026  | Sep 5 2026  | Two revision rounds completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Milestones tab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client     | Project          | Milestone              | Status      | Due Date    | Completed Date | Notes
Acme Corp  | Website Redesign | Discovery and planning | Complete    | Aug 5 2026  | Aug 4 2026     | Brief signed off
Acme Corp  | Website Redesign | Wireframes and design  | Complete    | Aug 15 2026 | Aug 14 2026    | Two revisions done
Acme Corp  | Website Redesign | Development            | In Progress | Aug 28 2026 |                | Homepage done
Acme Corp  | Website Redesign | Testing and launch     | Pending     | Sep 5 2026  |                |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Invoices tab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client     | Invoice  | Description              | Amount  | Status  | Due Date    | Paid Date
Acme Corp  | INV-001  | Website Design Phase 1   | $1,200  | Paid    | Aug 1 2026  | Jul 30 2026
Acme Corp  | INV-002  | Website Design Phase 2   | $1,200  | Pending | Aug 30 2026 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Documents tab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client     | Document Name      | Type     | Date Added  | URL                              | Notes
Acme Corp  | Project Proposal   | Proposal | Aug 1 2026  | https://drive.google.com/...     |
Acme Corp  | Brand Guidelines   | Design   | Aug 12 2026 | https://drive.google.com/...     |
Acme Corp  | Contract Agreement | Legal    | Aug 1 2026  | https://drive.google.com/...     |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Updates tab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client     | Date        | Title                        | Description                          | Category    | Author
Acme Corp  | Aug 21 2026 | Homepage development complete | Ready for your review.               | Development | Allen
Acme Corp  | Aug 19 2026 | Design revisions applied      | All feedback incorporated.           | Design      | Allen
Acme Corp  | Aug 15 2026 | Review call completed         | Wireframes approved. Moving forward. | Meeting     | Allen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For document URLs, paste the shareable link from Google Drive. Set the sharing to Anyone with the link can view.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hczicdsd27xe1363dl0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hczicdsd27xe1363dl0.png" alt="Screenshot of the Google Sheet with all five tabs visible and real client data filled in" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Connect the Sheet to Your Portal
&lt;/h2&gt;

&lt;p&gt;Go back to the SheetRocket portal editor. &lt;br&gt;
You will see a banner at the top of the &lt;br&gt;
canvas saying the portal is showing &lt;br&gt;
sample data.&lt;/p&gt;

&lt;p&gt;Click on any widget block in the canvas &lt;br&gt;
to open its settings in the left panel. &lt;br&gt;
Click the &lt;strong&gt;Data&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpw0viwow2qau3dbhkha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpw0viwow2qau3dbhkha.png" alt="Screenshot of the left panel Data tab showing the sample data banner and Add data source button" width="800" height="745"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You need to share your Google Sheet with the SheetRocket service account email before connecting it.&lt;/p&gt;

&lt;p&gt;Find the service account email in the &lt;br&gt;
Data tab. Click on "Add Data Source" for a modal to appear. You'll see the Google service account. Copy it. Go to your Google &lt;br&gt;
Sheet and click &lt;strong&gt;Share&lt;/strong&gt;. Paste the &lt;br&gt;
service account email. Set permission to Viewer. Click &lt;strong&gt;Share&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft2w5khcpnh3mpn2u9ddp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft2w5khcpnh3mpn2u9ddp.png" alt="Screenshot showing google service account email" width="799" height="564"&gt;&lt;/a&gt;&lt;/p&gt;





&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffw3ebu8zgx18ucpuw46l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffw3ebu8zgx18ucpuw46l.png" alt="Screenshot of the Google Sheets share dialog with the service account email pasted in and Viewer permission selected" width="800" height="642"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now copy the URL of your Google Sheet &lt;br&gt;
from your browser. Paste it into the &lt;br&gt;
Google Sheet URL field in the SheetRocket modal that opens. Click &lt;strong&gt;Connect&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92xkixa0rwgvin4r2z01.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92xkixa0rwgvin4r2z01.png" alt="Screenshot of the modal with the sheet URL pasted in and the Connect button visible" width="799" height="564"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SheetRocket reads your sheet automatically &lt;br&gt;
and runs auto-mapping to match your columns &lt;br&gt;
to the portal fields. You will see a success &lt;br&gt;
message showing how many columns were mapped automatically.&lt;/p&gt;

&lt;p&gt;The canvas will update immediately to show &lt;br&gt;
your real client data instead of sample data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5nk731i6z639z64xi3c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5nk731i6z639z64xi3c.png" alt="Screenshot of the portal canvas showing real client data: Acme Corporation, real project name, real milestone statuses" width="800" height="713"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will need to connect each widget block &lt;br&gt;
separately to the correct tab in your sheet. The milestones block connects to the Milestones tab. The invoices block connects to the Invoices tab. And so on.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 7: Configure Access Settings
&lt;/h2&gt;

&lt;p&gt;Click the &lt;strong&gt;Access&lt;/strong&gt; button in the top bar &lt;br&gt;
of the editor. This opens the portal access &lt;br&gt;
settings panel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp220obmdlxb12ftr5bes.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp220obmdlxb12ftr5bes.png" alt="Screenshot of the editor top bar with the lock icon Access button highlighted" width="800" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will see three access options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Private link (recommended)
  Only people with your unique link 
  can access the portal.
  This is the default.

Password protected
  Visitors must enter a password.
  Good for extra security.

Private link and password
  Both required for maximum security.
  Best for sensitive financial or 
  legal data.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most agency use cases, private link is sufficient. Your client receives the link. They click it and see their portal. &lt;br&gt;
Anyone else who tries to access the URL without the token sees an Access Denied page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe9h9j3xjpeqqe3c4xnc0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe9h9j3xjpeqqe3c4xnc0.png" alt="Screenshot of the Access settings panel showing the three access type options with Private link selected" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The private link is shown in the panel with a copy button. This is the same link from the success screen in Step 3.&lt;/p&gt;

&lt;p&gt;If you ever need to invalidate the link &lt;br&gt;
and generate a new one, click &lt;strong&gt;Generate new link&lt;/strong&gt;. Your client will need the updated link to access their portal again.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 8: Customise the Portal Branding
&lt;/h2&gt;

&lt;p&gt;The portal shows your agency name in the header by default. To add your logo and adjust the colors, click the portal settings.&lt;/p&gt;

&lt;p&gt;In the editor, find the portal config settings. You can set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agency name:     Your studio or company name
Agency logo URL: Link to your logo image
Primary color:   Your brand color
                 Used for active sidebar 
                 items and accents
Contact email:   Where client messages go
                 when they use the Messages
                 section
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dqt673e4u3o1uvt8r72.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dqt673e4u3o1uvt8r72.png" alt="Screenshot of the portal branding settings with agency name, logo URL, and primary color fields" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the Pro plan the SheetRocket branding &lt;br&gt;
in the portal footer is removed. On the &lt;br&gt;
Agency plan you can white label the portal &lt;br&gt;
completely with your own branding.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 9: Preview the Portal as
&lt;/h2&gt;

&lt;p&gt;Your Client&lt;/p&gt;

&lt;p&gt;Click the &lt;strong&gt;Client view&lt;/strong&gt; button in the top bar of the editor. This opens the published portal URL with your access token automatically included.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1sywfqnc24usjjm9holk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1sywfqnc24usjjm9holk.png" alt="Screenshot of the top bar with the Client view button shown" width="732" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will see the portal exactly as your client sees it when they click their private link.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhvinh5e1s9l4be0z1ekn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhvinh5e1s9l4be0z1ekn.png" alt="Screenshot of the published portal showing the sticky header with agency name, left sidebar navigation, welcome message, stats row with four cards, and preview sections" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click through the sidebar navigation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overview&lt;/strong&gt; shows the dashboard with stats, progress bar, and preview sections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project&lt;/strong&gt; shows the full milestones list with status badges and due dates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documents&lt;/strong&gt; shows all documents with download links.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoices&lt;/strong&gt; shows all invoices with &lt;br&gt;
paid and pending status badges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activity&lt;/strong&gt; shows the full chronological &lt;br&gt;
activity feed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Messages&lt;/strong&gt; shows a simple form to &lt;br&gt;
contact you directly.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 10: Share the Portal
&lt;/h2&gt;

&lt;p&gt;with Your Client&lt;/p&gt;

&lt;p&gt;Go back to your SheetRocket dashboard and &lt;br&gt;
click &lt;strong&gt;Sites&lt;/strong&gt;, then the &lt;strong&gt;Portals&lt;/strong&gt; tab. &lt;br&gt;
Find your portal card.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk86x2dxlq5kpxfu34vb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk86x2dxlq5kpxfu34vb.png" alt="Screenshot of the Portals list showing the Acme Corporation Portal card with client name, Private link badge, and Copy client link button" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Copy client link&lt;/strong&gt;. Send it to &lt;br&gt;
your client in an email.&lt;/p&gt;

&lt;p&gt;Here is a simple email template that works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subject: Your project portal is ready

Hi [Client name],

I have set up a private project portal 
for you where you can track everything 
happening with your website redesign.

Your portal link:
[paste the private link here]

Bookmark this link. You can check it 
anytime for project updates, milestone 
status, documents, and invoices.

If you have any questions, use the Messages section in the portal and 
I will get back to you.

[Your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When your client clicks the link, they see their portal immediately. No account creation. No password unless you set one. Just their private dashboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happens When the Project Updates
&lt;/h2&gt;

&lt;p&gt;Every time something changes on the project, you update your Google Sheet. The portal updates automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A milestone is completed:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Change the Status from In Progress to Complete in the Milestones tab. Add the completed date. The milestone shows with a green checkmark in the portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An invoice is sent:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add a new row to the Invoices tab. Set Status to Pending. The invoice appears in the portal immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A document is ready:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add a row to the Documents tab with the file name and the Google Drive shareable link. The document appears in the portal with a Download button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A project update happens:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add a row to the Updates tab with the date, title, and description. It appears in the activity feed in chronological order.&lt;/p&gt;

&lt;p&gt;Your client can visit their portal at any time and see the current state of their project. No email needed from you. &lt;br&gt;
No status call required. Everything they need is in one place.&lt;/p&gt;


&lt;h2&gt;
  
  
  Managing Multiple Clients
&lt;/h2&gt;

&lt;p&gt;If you have ten clients, you create ten portals. Each portal connects to its own Google Sheet. Each portal has its own private link.&lt;/p&gt;

&lt;p&gt;On the SheetRocket Agency plan at &lt;br&gt;
$39/month, you get unlimited portals. &lt;br&gt;
All ten clients are managed from one &lt;br&gt;
SheetRocket account.&lt;/p&gt;

&lt;p&gt;Compare this to alternatives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Building a custom portal:   $5,000 to $20,000
Basecamp:                   $99/month minimum
Softr:                      $49/month minimum
SheetRocket Agency:         $39/month flat
                            unlimited clients
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The math works in your favour the moment &lt;br&gt;
you have more than one client.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding Password Protection for
&lt;/h2&gt;

&lt;p&gt;Sensitive Clients&lt;/p&gt;

&lt;p&gt;For clients who share sensitive financial &lt;br&gt;
or legal information, you can add a password on top of the private link.&lt;/p&gt;

&lt;p&gt;Go to the Access settings panel in the &lt;br&gt;
editor. Select &lt;strong&gt;Private link and password&lt;/strong&gt;. &lt;br&gt;
Enter a password. Click Save.&lt;/p&gt;

&lt;p&gt;Send your client their private link and their password separately. When they open the link, they see a password gate page. They enter the password once, and the portal opens. Sheetrocket remembers their access for seven days via a secure cookie.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;A client portal changes the dynamic of client relationships. Instead of your client feeling in the dark between emails, they have a place they can go at any time to see exactly what is happening.&lt;/p&gt;

&lt;p&gt;It also reduces the back-and-forth that takes up your time. Where is the invoice? Check the portal. What stage are we at? Check the portal. Can I download the latest file? Check the portal.&lt;/p&gt;

&lt;p&gt;SheetRocket builds the portal from a Google Sheet you are already maintaining. There is no new system to learn. No data to migrate. You update the spreadsheet the way you always have. Your client sees the result in their portal.&lt;/p&gt;

&lt;p&gt;Start free at &lt;br&gt;
&lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;sheetrocket.com&lt;/a&gt;. &lt;br&gt;
The free plan includes one client portal. &lt;br&gt;
Upgrade to Pro at $19/month for unlimited &lt;br&gt;
portals with no SheetRocket branding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Allen Jones is the founder of SheetRocket, &lt;br&gt;
a tool that turns Google Sheets into websites, &lt;br&gt;
embeddable widgets, client portals, and REST &lt;br&gt;
APIs. Building in public from a small town in Ghana.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>AI Engineering for JavaScript Developers: What You Actually Need to Learn</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Wed, 16 Sep 2026 00:55:35 +0000</pubDate>
      <link>https://dev.to/allenarduino/ai-engineering-for-javascript-developers-what-you-actually-need-to-learn-ka5</link>
      <guid>https://dev.to/allenarduino/ai-engineering-for-javascript-developers-what-you-actually-need-to-learn-ka5</guid>
      <description>&lt;p&gt;"If I'm already a JavaScript developer, what do I actually need to learn to become an AI engineer?"&lt;/p&gt;

&lt;p&gt;That's the real question, and it deserves a real answer, not another "AI is changing the world" opener. If you already build software, you're closer to this than it feels. Frontend, backend, APIs, databases, authentication, queues, caching, infrastructure, SaaS architecture, all of that transfers directly. The missing piece isn't software engineering skill. It's understanding how AI models actually work and how to wire them into systems that stay reliable once real users and real data show up.&lt;/p&gt;

&lt;p&gt;This is a standalone guide to that missing piece, not an intro to a course. If you finish it, you should have a clear, technically grounded map of the field, not a list of buzzwords.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AI engineer actually does
&lt;/h2&gt;

&lt;p&gt;An AI engineer doesn't necessarily train models from scratch. Most don't. An AI engineer builds production applications and systems around existing models, while understanding enough of what's underneath to make sound engineering decisions on architecture, cost, latency, and reliability.&lt;/p&gt;

&lt;p&gt;There's a spectrum, and different roles sit at different points on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Calling an LLM API
        ↓
Building LLM applications
        ↓
RAG
        ↓
Tool calling
        ↓
Agents
        ↓
Evaluation
        ↓
Fine-tuning
        ↓
Model serving
        ↓
Model optimization
        ↓
Training models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An AI engineer usually lives in the upper half of that list. A machine learning engineer or researcher generally lives lower down. Neither is "more real" than the other; they're different jobs solving different problems, and this guide is specifically about the upper half, the part a JavaScript developer is already closest to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ti8xrnxa3ne8690mri1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ti8xrnxa3ne8690mri1.png" alt="AI engineering spectrum" width="800" height="841"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In practice, AI engineers build things like document processing pipelines, AI-powered search, customer support systems, agents that take action on a user's behalf, data analysis tools, recommendation systems, lead classification, content extraction, and business process automation. In almost every one of these, the AI model is one component of a larger system, not the whole system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    User
                      ↓
                 Application
                      ↓
                Backend/API
                      ↓
              AI orchestration
              /       |       \
             ↓        ↓        ↓
           RAG      Tools    Database
             \        |        /
              \       |       /
                   AI Model
                      ↓
                Structured output
                      ↓
                 Application
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57rjgjmbsrautm2q1r9f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57rjgjmbsrautm2q1r9f.png" alt="AI application architecture" width="800" height="731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most of the actual engineering happens around the model, not inside it. That's the part that looks exactly like the SaaS architecture you already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI engineer versus machine learning engineer
&lt;/h2&gt;

&lt;p&gt;Real job titles overlap constantly, and different companies use these terms inconsistently, so treat this as a rough map, not a rulebook.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;AI engineer&lt;/strong&gt; usually focuses on building AI-powered applications: integrating models, LLM APIs, RAG, embeddings, vector databases, agents, tool calling, evaluation, AI security, observability, cost optimization, production architecture, inference, and model selection.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;machine learning engineer&lt;/strong&gt; more often focuses on training models directly: data pipelines, feature engineering, model training, experimentation, deployment of trained models, classical ML, deep learning, and model optimization at the training level.&lt;/p&gt;

&lt;p&gt;If your background is JavaScript and TypeScript, the AI engineer side of that list is the one that reuses almost everything you already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an LLM actually is
&lt;/h2&gt;

&lt;p&gt;A large language model is a model trained on enormous amounts of text to predict what token is likely to come next, given everything that came before it. "Model" here means what it usually means in software: a system that's learned patterns from data and can apply them to new input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Training data
     ↓
Training
     ↓
Model weights
     ↓
Trained model
     ↓
Inference
     ↓
Output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Training is the expensive, offline process of adjusting a model's internal parameters, its weights, based on massive datasets. Inference is what happens every time you send it a prompt afterward: running your input through those already-trained weights to get an output. As a developer building applications, you're almost always working at the inference stage. Training is a different job entirely, and one this guide isn't trying to prepare you for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzfwgwypjiqkvpfdjx7r0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzfwgwypjiqkvpfdjx7r0.png" alt="Training vs inference" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  API versus model
&lt;/h2&gt;

&lt;p&gt;This distinction matters more than it sounds like it should.&lt;/p&gt;

&lt;p&gt;An API is the interface your application uses to talk to a model. The actual neural network computation happens somewhere behind that interface, on infrastructure you don't see and usually don't need to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your TypeScript application
          ↓
        HTTP/API
          ↓
       AI provider
          ↓
        LLM model
          ↓
       Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo170mvf0jvowbotb42gf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo170mvf0jvowbotb42gf.png" alt="API vs model" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In code, that's just an HTTP call with extra ceremony:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;some-model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Classify this lead.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's application-level code, no different in kind from calling any other third-party API. Knowing how to call it is genuinely useful; it's how the Smart Inbox feature I built into Formgrid.dev works, sending a submission's text to Gemini Flash and getting a structured category and priority back. But knowing how to call an API isn't, by itself, deep AI engineering knowledge, any more than knowing how to call Stripe's API makes someone a payments engineer. The depth is in everything around that call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens when you send a prompt
&lt;/h2&gt;

&lt;p&gt;A simplified but accurate version of the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your text
   ↓
Tokenization
   ↓
Token IDs
   ↓
Numerical representations
   ↓
Transformer
   ↓
Attention + neural network operations
   ↓
Logits
   ↓
Probabilities/token selection
   ↓
Next token
   ↓
Repeat
   ↓
Output text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcexy6tog4tihd34k3pht.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcexy6tog4tihd34k3pht.png" alt="What happens when you send a prompt" width="800" height="305"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Your text gets broken into tokens, converted into numbers, and run through a transformer architecture that uses attention (a mechanism for weighing which earlier tokens matter most for predicting the next one) to produce a probability distribution over what token should come next. One token gets selected, gets appended to the sequence, and the whole process repeats to generate the token after that. This isn't magic, and it isn't mysterious; it's a lot of matrix math applied repeatedly, but you don't need to derive the math to reason well about the systems you build on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokens
&lt;/h2&gt;

&lt;p&gt;A token is the actual unit a model processes, and it's not the same thing as a word. Common words might be a single token. Longer or less common words often split into several tokens. Punctuation and spaces can be their own tokens too. Exactly how text splits into tokens depends on the specific tokenizer a model uses; it isn't universal across providers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Text
 ↓
Tokenizer
 ↓
Tokens
 ↓
Token IDs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commonly cited approximations, roughly three-quarters of a word per token, or roughly four characters per token, are just that: rough approximations, not a conversion rate you should build precise logic around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tokens matter for SaaS economics
&lt;/h2&gt;

&lt;p&gt;Every request to a model consumes input tokens (what you send) and output tokens (what it generates), and providers usually price these two differently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input tokens
+
Output tokens
=
Token usage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say, purely for illustration, a provider charged for 1,000 input tokens and 200 output tokens on a given call. Since actual pricing varies by model and provider and changes over time, any specific numbers here are just for showing the shape of the calculation, not something to copy into a cost model.&lt;/p&gt;

&lt;p&gt;The shape of the request matters a lot. A cheap AI operation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lead submission
 ↓
Classification
 ↓
"High intent"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small input, tiny output; this is close to what the Smart Inbox does: read a submission, return a category, a priority, and a summary. A more expensive operation looks like this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lead submission
 ↓
Large context
 ↓
Long reasoning/generation
 ↓
500-word personalized response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same starting point, far more output and context, and therefore a meaningfully different cost profile. Designing an AI feature is partly a unit economics problem: what you're asking the model to read and produce directly shapes what the feature costs to run at scale, which is exactly the kind of thing that matters once a feature has real users instead of a demo audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context windows
&lt;/h2&gt;

&lt;p&gt;A context window is the amount of tokenized information a model can process within a single inference request. You can think of it loosely as working memory, though technically it's the model's available context for that one request, not a persistent memory of anything.&lt;/p&gt;

&lt;p&gt;What can occupy that context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System instructions
+
Conversation history
+
User message
+
Documents
+
Retrieved information
+
Tool results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The context window is measured in tokens, and it covers both what you send in and what the model generates back, subject to whatever specific limits a given model and API impose. It's not that the model "remembers" everything you've ever sent it; it's that everything relevant has to be included again, explicitly, in each request that needs it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fznaznslj4ehx4axkt1xy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fznaznslj4ehx4axkt1xy.png" alt="The context window" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Context versus memory
&lt;/h2&gt;

&lt;p&gt;These get conflated constantly, and the distinction matters for anything beyond a single request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context&lt;/strong&gt; is information available to the model during the current inference call, and only that call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory&lt;/strong&gt; is information stored externally, in a database or a product's own memory system, and deliberately retrieved and re-added to context later, when it's relevant again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User information
      ↓
Database/memory store
      ↓
Retrieve when relevant
      ↓
Context
      ↓
LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any application that needs to "remember" something across sessions is building memory as a feature, on top of the model, not getting it for free from the model itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rkjzwh52iusf25zoyri.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rkjzwh52iusf25zoyri.png" alt="Context vs memory" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why context windows matter in practice
&lt;/h2&gt;

&lt;p&gt;A short legal contract might fit entirely inside a model's context window, letting it process the whole thing directly. A much longer document usually can't, or shouldn't, which is exactly the situation that pushes toward chunking and retrieval instead of dumping everything in at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Huge document
 ↓
Chunking
 ↓
Retrieval
 ↓
Relevant sections
 ↓
LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same tension shows up with large codebases: a bigger context window lets you supply more code, but simply pasting an entire repository into context isn't automatically good architecture, and with long-running conversations, you need an actual strategy: summarization, truncation, retrieval, external memory, or some mix of them, rather than letting the transcript grow forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bigger context is not automatically better
&lt;/h2&gt;

&lt;p&gt;Worth stating explicitly, since it's an easy trap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Larger context ≠ automatically better AI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More context you don't need costs more, adds latency, dilutes the model's attention with irrelevant information, and burns computation for no benefit. The actual engineering principle is narrower and more useful: give the model the information it needs for this specific task, not everything you happen to have lying around. That principle is exactly what leads naturally into retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings, briefly
&lt;/h2&gt;

&lt;p&gt;An embedding is a numerical representation that lets software work with the semantic relationship between pieces of text, not just their literal wording.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"I want to buy a car."
        ↓
     embedding
        ↓
[0.12, -0.43, 0.81, ...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sentence with different wording but a similar meaning produces a similar vector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"I'm looking to purchase an automobile."
        ↓
     embedding
        ↓
[a similarly shaped vector]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Embeddings power semantic search, document retrieval, recommendations, clustering, and RAG. Worth being precise here: an embedding is a learned numerical pattern useful for measuring similarity and relationships, not a literal, human-readable encoding of "meaning" the way a dictionary definition is.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdgr6t9arwj1xdlqkdcd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdgr6t9arwj1xdlqkdcd.png" alt="Text to embeddings" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG, in outline
&lt;/h2&gt;

&lt;p&gt;RAG stands for retrieval-augmented generation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Documents
   ↓
Chunking
   ↓
Embeddings
   ↓
Vector database
   ↓
User question
   ↓
Retrieve relevant information
   ↓
Add relevant information to context
   ↓
LLM
   ↓
Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fowhjzgns15dave7qlp88.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fowhjzgns15dave7qlp88.png" alt="How RAG works with Formgrid.dev" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea RAG solves: a model doesn't need every document you own sitting in its context for every question. Instead, the application retrieves just the pieces relevant to the current question and adds only those to the prompt. It's one of the most common patterns in production AI systems, and it's worth knowing clearly that RAG improves the odds of a grounded, factual answer; it doesn't eliminate hallucination outright. A retrieved passage can still be misread or misapplied by the model generating from it. The deeper mechanics, chunking strategy, reranking, choosing a vector database, are enough material for their own dedicated piece rather than a paragraph here.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI applications are more than an LLM call
&lt;/h2&gt;

&lt;p&gt;A simple wrapper looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
 ↓
Prompt
 ↓
LLM API
 ↓
Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a legitimate, useful way to prototype an idea fast. A production system usually looks considerably more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
 ↓
Application
 ↓
Authentication
 ↓
Input validation
 ↓
Context retrieval
 ↓
RAG
 ↓
Model routing
 ↓
LLM
 ↓
Tool calls
 ↓
Output validation
 ↓
Business logic
 ↓
Database
 ↓
Response
 ↓
Evaluation/observability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where most of what actually counts as AI engineering happens, and it's also the part that looks the most like ordinary backend engineering: auth, validation, routing, persistence, observability, all familiar, just with a model call somewhere in the middle of the flow instead of a plain database query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents
&lt;/h2&gt;

&lt;p&gt;An agentic system combines a model with tools, some state, a set of instructions, and a loop that lets it act, observe the result, and decide what to do next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LLM
+
Tools
+
State
+
Instructions
+
Execution loop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, a user asks: "Find my unpaid invoices and email the oldest customer."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:
"Find my unpaid invoices and email the oldest customer."

                 ↓

              AI Agent
              /      \
             ↓        ↓
      getInvoices()  sendEmail()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail: the model can request that a tool be called; it doesn't directly execute anything itself. Your application decides whether that request is actually allowed and executes it, or doesn't. Authorization and security stay entirely under application control, exactly as they would for any user-initiated action, never delegated to the model's judgment about what it should be allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation
&lt;/h2&gt;

&lt;p&gt;A question a lot of people building AI features skip past: how do you actually know your AI system works, beyond it feeling right in a few manual tests?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Test cases
   ↓
AI system
   ↓
Expected vs actual
   ↓
Evaluation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This covers evaluation datasets, accuracy metrics where they apply, hallucination checks, retrieval quality, tool call success rates, regression testing across prompt or model changes, and human review where automated evaluation isn't enough. Changing a prompt, or swapping to a newer model, can silently make a system worse in ways that never show up until a real user hits the case you didn't test. Evaluation is what catches that before your users do.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI security
&lt;/h2&gt;

&lt;p&gt;A short but important list: prompt injection, data leakage, tool authorization boundaries, sensitive information exposure, untrusted content making its way into a prompt, and agents with more permission than the task actually requires.&lt;/p&gt;

&lt;p&gt;The governing principle: never treat a model's output as inherently trustworthy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LLM says:
"Delete the account."

Application:
"Does this user actually have permission?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application enforces authorization independently, every time, regardless of what the model suggests or requests. A model's output is an input to your business logic, not a substitute for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost optimization
&lt;/h2&gt;

&lt;p&gt;The real levers, roughly in order of how often they matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model selection
Context size
Token usage
Caching
Model routing
Batching
Smaller models
Prompt caching
Efficient inference
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The habit worth building is reasoning in three dimensions at once, quality, latency, and cost, rather than defaulting to "use the biggest, most capable model available." That's exactly the reasoning behind picking Gemini Flash for Formgrid's classification feature: a six-category classification with a summary doesn't need a frontier model's full reasoning capability, and paying for that capability on every single form submission, indefinitely, at scale, would have been a real, ongoing cost with no corresponding benefit to the actual task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do JavaScript developers need Python?
&lt;/h2&gt;

&lt;p&gt;Not to get started, no. TypeScript and Node.js are genuinely capable of building production AI applications: APIs, orchestration, agents, streaming responses, and the surrounding SaaS infrastructure all work fine in the ecosystem you already know.&lt;/p&gt;

&lt;p&gt;Python matters more once you're working closer to the model itself: PyTorch, Hugging Face, dataset preparation, fine-tuning, research tooling, and a large share of the open-source ML ecosystem live there and don't have equivalents as mature in JavaScript. The honest recommendation: keep TypeScript as your primary strength, and learn enough Python to be comfortable reading and adapting code in that ecosystem when you need to, not to switch your whole practice over to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much math do you actually need?
&lt;/h2&gt;

&lt;p&gt;Depends which side of the earlier spectrum you're aiming at.&lt;/p&gt;

&lt;p&gt;Building AI applications well benefits from a practical grasp of vectors, embeddings, basic probability, similarity, basic statistics, and a conceptual understanding of what attention and neural networks are doing, enough to reason about behavior, not enough to derive it from scratch.&lt;/p&gt;

&lt;p&gt;Deep ML engineering or research requires meaningfully more: real linear algebra, calculus, probability and statistics at depth, optimization theory, and numerical methods.&lt;/p&gt;

&lt;p&gt;You don't need to become a mathematician to build good AI applications. You do need to understand the concepts well enough that you're reasoning about a system, not treating it as a black box that occasionally does something surprising for no traceable reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually learn, in order
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. LLM fundamentals
   ↓
2. Tokens
   ↓
3. Context windows
   ↓
4. Embeddings
   ↓
5. Vector search
   ↓
6. RAG
   ↓
7. Structured outputs
   ↓
8. Tool/function calling
   ↓
9. Agents
   ↓
10. Evaluation
   ↓
11. AI security
   ↓
12. Observability
   ↓
13. Cost optimization
   ↓
14. Fine-tuning
   ↓
15. Open source models
   ↓
16. Model serving/inference
   ↓
17. Production AI architecture
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alongside that list, layered in over time rather than upfront: enough Python to navigate the ecosystem, ML fundamentals, and basic linear algebra and probability. The first twelve or thirteen items on that list are genuinely learnable by building things. Fine-tuning, model serving, and inference optimization are where things start requiring more specialized, ML-adjacent knowledge, and they're reasonable to treat as later, not first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Projects worth building while learning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An AI lead classifier.&lt;/strong&gt; Form submission goes in, an LLM call returns a structured classification, and it gets stored. This teaches tokens, structured outputs, model selection, evaluation, and cost, in a scope small enough to actually finish. It's also, not coincidentally, close to what Formgrid's Smart Inbox already does in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A document Q&amp;amp;A tool.&lt;/strong&gt; Documents get embedded, stored in a vector database, retrieved based on a user's question, and fed to an LLM alongside that question to generate a grounded answer. This teaches embeddings, vector search, RAG, context management, and citing sources correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A small AI agent.&lt;/strong&gt; A user gives an instruction, an agent decides which tools to call, calls real APIs, and reports back. This teaches tool calling, state management, permission boundaries, agent loops, and evaluation of a much less predictable system than a single classification call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An AI cost optimizer.&lt;/strong&gt; Take an existing AI feature and work on model routing, caching, and token usage to bring the cost down without hurting quality. This teaches the quality, latency, and cost tradeoff directly, which is a different skill from building the feature in the first place.&lt;/p&gt;

&lt;p&gt;Treat these as real, extendable projects rather than disposable tutorials, the same way Formgrid started as a project before it became a product with paying customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AI engineer can actually build
&lt;/h2&gt;

&lt;p&gt;AI document processing platforms, AI customer support systems, AI sales operations tooling, contract management, accounts payable automation, research tools, data analysis systems, workflow automation, developer tools, lead intelligence platforms, and AI-native vertical SaaS products aimed at a specific industry's actual workflow.&lt;/p&gt;

&lt;p&gt;The opportunity here isn't "build another chatbot." It's using a model to meaningfully automate or improve a workflow a business already has and already pays to run less efficiently today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model to leave with
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  AI APPLICATION
                       │
       ┌───────────────┼────────────────┐
       ↓               ↓                ↓
     Data            Models           Tools
       │               │                │
       ↓               ↓                ↓
   Retrieval         LLM            APIs/actions
       │               │                │
       └───────────────┼────────────────┘
                       ↓
                  AI SYSTEM
                       │
              ┌────────┼────────┐
              ↓        ↓        ↓
           Evaluate  Secure   Optimize
              │        │        │
              └────────┼────────┘
                       ↓
                 REAL PRODUCT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An AI engineer isn't simply someone who knows how to call an LLM API. It's someone who understands enough about models, data, context, retrieval, tools, evaluation, security, and infrastructure to build AI systems that hold up once real users and real load show up, not just in a demo.&lt;/p&gt;

&lt;p&gt;That said, calling APIs well is still a real, necessary skill, not a lesser one. The difference between that and the rest of this list is depth of understanding and the ability to engineer the whole system around the call, not just the call itself. If you're a JavaScript developer starting from here, you already have most of what the systems half of that sentence requires. The model half is genuinely learnable, in the order laid out above, one real project at a time.&lt;/p&gt;

&lt;p&gt;If you're working through this same map yourself, I'd like to hear what you're building. Reach me at &lt;a href="mailto:allen@formgrid.dev"&gt;allen@formgrid.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Allen, a full-stack TypeScript engineer and the founder of &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt; and &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;SheetRocket&lt;/a&gt;. I write about real production engineering from products that people actually pay for. More at &lt;a href="https://jonesstack.com" rel="noopener noreferrer"&gt;jonesstack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiengineering</category>
      <category>javascript</category>
      <category>embeddings</category>
      <category>vectordatabases</category>
    </item>
    <item>
      <title>JavaScript and TypeScript Interview Questions Explained With Real Production Examples, Part 2: Algorithms</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Wed, 16 Sep 2026 00:34:37 +0000</pubDate>
      <link>https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-part-2-47ip</link>
      <guid>https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-part-2-47ip</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e"&gt;Part one of this series&lt;/a&gt; covered scope, closures, promises, the event loop, and TypeScript's type system. This part covers the four data structure and algorithm concepts that come up constantly in interviews, and constantly in real code, if you know where to look for them.&lt;/p&gt;

&lt;p&gt;Same approach as before: a plain definition first, for anyone just here to check their understanding, then a real example, not a toy one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Array, Set, and Map
&lt;/h2&gt;

&lt;p&gt;An array is an ordered collection you access by position, or index. A Set stores a collection of values with no duplicates allowed, and its main job is answering "have I seen this before" efficiently. A Map stores key-value pairs, and its main job is answering "given this key, what's the value" efficiently.&lt;/p&gt;

&lt;p&gt;The three get confused because they can all technically "hold a list of things," but they answer completely different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Array&lt;/strong&gt;, when order and position matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recentSubmissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;submission1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;submission2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;submission3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;recentSubmissions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// the first one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this when you genuinely care about sequence, or need to iterate in a specific order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set&lt;/strong&gt;, when you only care about membership:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectedLeadIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;selectedLeadIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lead_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;selectedLeadIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lead_456&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;selectedLeadIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lead_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the shape of a bulk selection feature, checking a batch of rows in a table to apply a status update to all of them at once. The question you're asking is always some version of "is this one selected," and a &lt;code&gt;Set&lt;/code&gt; answers that in constant time instead of scanning an array on every check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Map&lt;/strong&gt;, when you need a fast lookup by key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leadsById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Lead&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;leadsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;leadsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lead_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the full lead object&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anywhere you'd otherwise write &lt;code&gt;array.find(item =&amp;gt; item.id === someId)&lt;/code&gt; inside a loop, a &lt;code&gt;Map&lt;/code&gt; built once up front turns repeated linear scans into repeated constant time lookups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding duplicates with a Set
&lt;/h2&gt;

&lt;p&gt;Say your backend receives a list of email addresses during a bulk import, a CSV of contacts someone's uploading into their account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allen@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;john@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allen@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need to flag which ones are duplicated before importing. A &lt;code&gt;Set&lt;/code&gt; gives you a clean way to track what's already been seen while walking the list exactly once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;duplicates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;duplicates&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// ["allen@example.com"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a different shape of problem from the classic two sum pattern I covered in &lt;a href="https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e"&gt;an earlier post on production JavaScript patterns&lt;/a&gt;, even though both lean on a hash-based structure. Two sum is about finding a relationship between two different values that together satisfy some condition. This is simpler: just tracking whether a single value has shown up before. Same underlying idea, a hash-based structure turning repeated scans into constant time checks, applied to a narrower question. The same pattern covers duplicate user IDs, duplicate product SKUs, and duplicate database records just as directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Big O
&lt;/h2&gt;

&lt;p&gt;Big O describes how the amount of work a piece of code does grows as its input grows. It's not a measurement of actual speed; it's a description of the growth curve, which matters more than the raw number once your data gets large enough.&lt;/p&gt;

&lt;p&gt;Here's the version that actually shows up in production, not the whiteboard version. Suppose you have a list of users and a separate list of profiles, and for every user you need to find their matching profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 100 users and 100 profiles, this does roughly 10,000 comparisons in the worst case, &lt;code&gt;.find()&lt;/code&gt; scanning the whole profiles array for every single user. That's O(n²), quadratic, because the work grows with the product of both list sizes. At 100,000 users and 100,000 profiles, that's roughly ten billion comparisons, the kind of thing that turns a fast endpoint into a timeout with no code change other than more data showing up.&lt;/p&gt;

&lt;p&gt;The fix is the same &lt;code&gt;Map&lt;/code&gt; pattern from the section above, built once, ahead of time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profileMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;profileMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building the map is O(n). Every lookup afterward is constant time. Total cost: O(n), not O(n²). Same result, a completely different growth curve once real data volume shows up. This is the actual reason Big O gets asked about in interviews, not to test whether you can recite notation, but to see whether you notice a nested nested nested loop before it ships and becomes a production incident.&lt;/p&gt;

&lt;p&gt;The handful worth actually knowing by shape, not by memorized name: constant time regardless of input size, logarithmic where doubling the input barely adds work, linear where work grows directly with input, linearithmic which is what most efficient sorting algorithms cost, and quadratic, which is the one to watch for, since it's the one that quietly turns fine into broken as data grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Binary search
&lt;/h2&gt;

&lt;p&gt;Binary search finds a value in a sorted collection by repeatedly cutting the remaining search space in half, instead of checking every element in order.&lt;/p&gt;

&lt;p&gt;Picture a sorted list of customer IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100, 200, 300, 400, 500, 600, 700
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looking for &lt;code&gt;600&lt;/code&gt;, you don't start at the front and check each one in sequence. You check the middle, &lt;code&gt;400&lt;/code&gt;. &lt;code&gt;600&lt;/code&gt; is bigger, so everything to the left of &lt;code&gt;400&lt;/code&gt; is eliminated in one comparison; no need to ever look at it. What's left is &lt;code&gt;500, 600, 700&lt;/code&gt;. Check the middle again: &lt;code&gt;600&lt;/code&gt;. Found it in two comparisons instead of six.&lt;/p&gt;

&lt;p&gt;That halving is exactly why binary search runs in logarithmic time: every comparison eliminates half of what's left, so the number of comparisons needed grows far slower than the size of the data. A sorted list of a million records finds any value in roughly twenty comparisons. Double the data to two million, and it only costs one more comparison, not double the work.&lt;/p&gt;

&lt;p&gt;The condition worth remembering, since it's the part beginners forget, and the part an interviewer is often specifically checking for: binary search only works on sorted data. Run it against an unsorted list, and it'll return wrong answers confidently, not an error, which is a worse failure mode than crashing outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this connects
&lt;/h2&gt;

&lt;p&gt;Array, Set, and Map aren't three interchangeable ways to store a list; they're three different questions: what order is this in, have I seen this before, and what value belongs to this key. Big O is the language for describing what happens to your chosen structure once real data volume shows up, and binary search is a concrete example of an algorithm that only gets its efficiency because it assumes something specific about its input, sortedness, that most naive implementations don't bother to require or exploit.&lt;/p&gt;

&lt;p&gt;The next post in this series moves from data structures into testing and debugging: how to actually test a new feature properly, what makes a good regression test, and how to diagnose a production API that suddenly got slow without guessing.&lt;/p&gt;

&lt;p&gt;If you've hit one of these patterns in a real codebase and would explain it differently, I'd like to hear it. Reach me at &lt;a href="mailto:allen@formgrid.dev"&gt;allen@formgrid.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Allen, a full stack TypeScript engineer and the founder of &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt; and &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;SheetRocket&lt;/a&gt;. I write about real production engineering from products that people actually pay for. More at &lt;a href="https://jonesstack.com" rel="noopener noreferrer"&gt;jonesstack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>interview</category>
      <category>javascript</category>
      <category>typescript</category>
    </item>
    <item>
      <title>JavaScript and TypeScript Interview Questions Explained With Real Production Examples, Part 1</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Thu, 10 Sep 2026 01:20:07 +0000</pubDate>
      <link>https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e</link>
      <guid>https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e</guid>
      <description>&lt;p&gt;Most explanations of these concepts stop at the definition. Scope gets a rule about hoisting. Closures get a counter function that increments a number nobody would ever ship. The event loop gets a diagram. &lt;code&gt;any&lt;/code&gt; versus &lt;code&gt;unknown&lt;/code&gt; gets a one-line rule. Generics get a toy example with numbers and strings that never shows up in a real codebase.&lt;/p&gt;

&lt;p&gt;That's enough to answer a question in an interview. It's not enough to actually recognize these patterns when they show up in your own code, which is the thing that matters once you're past the interview and actually shipping something.&lt;/p&gt;

&lt;p&gt;This is the first in a short series working through concepts that come up constantly in interviews, explained the way I'd actually explain them to someone on my team, with real examples from production code, not just the textbook version.&lt;/p&gt;

&lt;h2&gt;
  
  
  var, let, and const
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;var&lt;/code&gt;, &lt;code&gt;let&lt;/code&gt;, and &lt;code&gt;const&lt;/code&gt; are the three ways to declare a variable in JavaScript. &lt;code&gt;var&lt;/code&gt; is function-scoped and can be redeclared and reassigned freely. &lt;code&gt;let&lt;/code&gt; is block-scoped and can be reassigned but not redeclared in the same scope. &lt;code&gt;const&lt;/code&gt; is block-scoped and can't be reassigned after its initial value is set, though an object or array it points to can still be mutated internally.&lt;/p&gt;

&lt;p&gt;The rule everyone learns is "use &lt;code&gt;const&lt;/code&gt; by default, &lt;code&gt;let&lt;/code&gt; when a value changes, avoid &lt;code&gt;var&lt;/code&gt;." The part that's harder to see from that rule alone is what it looks like once real state is involved, not just a single reassignment.&lt;/p&gt;

&lt;p&gt;Take pagination on a leads or submissions list, the kind of thing &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid.dev's&lt;/a&gt; dashboard actually does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nextPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currentPage&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pageSize&lt;/code&gt; never changes for the life of that list, so &lt;code&gt;const&lt;/code&gt; says so directly. &lt;code&gt;currentPage&lt;/code&gt; changes every time someone clicks through the pagination, so &lt;code&gt;let&lt;/code&gt; is the honest choice. The value of picking correctly here isn't stylistic; it's that anyone reading this code later can tell, without tracing every line, which values are safe to assume are fixed and which ones move. &lt;code&gt;var&lt;/code&gt; doesn't offer that signal at all, and it doesn't respect block scope the way &lt;code&gt;let&lt;/code&gt; and &lt;code&gt;const&lt;/code&gt; do, which is reason enough to leave it out of new code entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closures
&lt;/h2&gt;

&lt;p&gt;A closure is a function that keeps access to variables from the scope it was created in, even after that outer scope has already finished running.&lt;/p&gt;

&lt;p&gt;The clearest real-world use of a closure is a small API client factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createApiClient()&lt;/code&gt; finishes running the moment it returns, but the object it hands back still remembers &lt;code&gt;baseUrl&lt;/code&gt;. That's the closure: &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;post&lt;/code&gt; keep access to a variable from a scope that's technically already finished executing.&lt;/p&gt;

&lt;p&gt;The reason this matters beyond the definition is what it lets you do: create multiple independent clients from the same function, each remembering its own configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;productionApi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stagingApi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://staging.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each one is a separate closure over a different &lt;code&gt;baseUrl&lt;/code&gt;, with zero shared state between them. This is the same shape I'd reach for anywhere a function needs to remember configuration without a class: event handlers, callbacks, factories, or anything that needs private state without exposing it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promise.all, when requests don't depend on each other
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Promise.all()&lt;/code&gt; takes an array of promises and resolves once every one of them has resolved, returning their results in the same order. If any single promise in the array rejects, &lt;code&gt;Promise.all()&lt;/code&gt; rejects immediately, discarding the results of everything else.&lt;/p&gt;

&lt;p&gt;Formgrid's dashboard loads several independent things on page load: lead pipeline stats, recent submissions, integration status, and account usage. None of them depend on each other's results.&lt;/p&gt;

&lt;p&gt;The naive version waits for each one in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchPipelineStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchRecentSubmissions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;integrations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchIntegrationStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchAccountUsage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If each of those takes something like 500ms, 800ms, 600ms, and 300ms, waiting for them one at a time adds up to roughly 2.2 seconds before the dashboard can render anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;fetchPipelineStats&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchRecentSubmissions&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchIntegrationStatus&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchAccountUsage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run concurrently instead; the total time is close to the slowest single request, around 800ms, not the sum of all four. &lt;code&gt;Promise.all&lt;/code&gt; is the right tool specifically when the requests are genuinely independent, and you need all of them to proceed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promise.allSettled, when one failure shouldn't take down the rest
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Promise.allSettled()&lt;/code&gt; also takes an array of promises, but it waits for every one of them to finish regardless of outcome, and returns an array describing each result as either fulfilled, with its value, or rejected, with its reason. Nothing gets discarded because one promise failed.&lt;/p&gt;

&lt;p&gt;The catch with &lt;code&gt;Promise.all&lt;/code&gt; is that a single rejection fails the entire batch. That's fine when every result is required before anything can render. It's the wrong choice when one piece failing shouldn't block the others.&lt;/p&gt;

&lt;p&gt;Say the AI Smart Inbox category breakdown widget on that same dashboard has a bad moment and its request fails, while the lead stats, submissions, and integrations requests all succeed fine. With &lt;code&gt;Promise.all&lt;/code&gt;, that one failure would throw away three perfectly good results along with it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;fetchPipelineStats&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchRecentSubmissions&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchIntegrationStatus&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchAiInboxBreakdown&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Loaded:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the dashboard can render the three sections that succeeded and show a quiet error state only where the AI breakdown actually failed. &lt;code&gt;Promise.allSettled&lt;/code&gt; is the right call whenever a partial result is genuinely more useful than an all-or-nothing failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The event loop isn't just a diagram; it's why setTimeout is deliberately used in real apps
&lt;/h2&gt;

&lt;p&gt;The event loop is the mechanism that lets JavaScript, a language that runs on a single thread, handle asynchronous work without blocking. It continuously checks whether the call stack is empty, and once it is, pulls the next pending callback onto the stack to run, microtasks first, then the task queue where things like &lt;code&gt;setTimeout&lt;/code&gt; callbacks wait.&lt;/p&gt;

&lt;p&gt;Start with a concept that trips people up: fire and forget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;sendEmail()&lt;/code&gt; starts an asynchronous operation and you don't &lt;code&gt;await&lt;/code&gt; it, your code doesn't wait around for it to finish. It starts the operation, moves on immediately, and the email finishes sometime later, off to the side. Compare that to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which waits for the email to fully send before moving on. The fire-and-forget version is genuinely useful in production; it's the same pattern behind background jobs that shouldn't block a response, but it comes with a real catch: if that unawaited promise rejects, you get an unhandled rejection nobody's watching for. Fire and forget on purpose, with a &lt;code&gt;.catch()&lt;/code&gt; attached, is a deliberate choice. Fire and forget by accident, because you forgot the &lt;code&gt;await&lt;/code&gt;, is a bug waiting to surface in production logs.&lt;/p&gt;

&lt;p&gt;That same asynchronous mental model is exactly what makes &lt;code&gt;setTimeout&lt;/code&gt; more than an interview trivia question. It's genuinely useful, constantly, in real applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Showing a temporary success message.&lt;/strong&gt; Say a form submission on Formgrid succeeds and you want to show "Form created successfully" for three seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;showSuccessMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JavaScript doesn't freeze for three seconds waiting for this. It shows the message, keeps doing whatever else it needs to do, and the event loop picks the hide callback back up once the timer's done and the call stack is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debouncing a search box.&lt;/strong&gt; This is the one that shows up in real products constantly. If a user types "formgrid" one character at a time, you don't want to fire an API request on every keystroke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;searchUsers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users?search=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every keystroke cancels the previous timer and starts a new one. Only once the user actually stops typing for 500 milliseconds does the request go out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrying a failed request after a delay.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Request failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retrying...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;fetchData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setTimeout&lt;/code&gt; here is scheduling future work deliberately, giving a flaky dependency a couple of seconds to recover before trying again, rather than hammering it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breaking up expensive work so the page doesn't freeze.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processChunk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// process some work&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moreWork&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;processChunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;processChunk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;0&lt;/code&gt; here doesn't mean "run immediately." It means "let the current work finish and give the event loop a chance to handle anything else that's queued before running this next chunk."&lt;/p&gt;

&lt;h3&gt;
  
  
  Microtasks jump the queue ahead of setTimeout, even at zero delay
&lt;/h3&gt;

&lt;p&gt;Here's the example that actually separates people who've memorized the event loop diagram from people who've internalized it. Imagine a user clicks "Generate Report":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Starting report&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Report notification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Update UI&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Request submitted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Starting report
Request submitted
Update UI
Report notification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not what most people guess on first read. The synchronous lines run first, in order: &lt;code&gt;Starting report&lt;/code&gt;, then &lt;code&gt;Request submitted&lt;/code&gt;. Then, before the &lt;code&gt;setTimeout&lt;/code&gt; callback gets anywhere near the call stack, the promise's &lt;code&gt;.then()&lt;/code&gt; callback runs, because promise callbacks go into the microtask queue, and the microtask queue is fully drained before the event loop even looks at the task queue where &lt;code&gt;setTimeout&lt;/code&gt; callbacks live. Only after that does &lt;code&gt;Report notification&lt;/code&gt; finally run.&lt;/p&gt;

&lt;p&gt;This is exactly why a &lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; never means "run this immediately." It means "run this after the current synchronous code finishes, and after every pending microtask has already run." Debugging unexpected execution order in a React app, a Node service, or anything mixing promises with timers almost always comes back to this exact ordering.&lt;/p&gt;

&lt;p&gt;If you're asked about this in an interview, debouncing is the strongest real-world example for &lt;code&gt;setTimeout&lt;/code&gt; specifically, and this report generation example is the strongest one for explaining why microtasks and macrotasks aren't the same queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  any versus unknown in TypeScript
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;any&lt;/code&gt; and &lt;code&gt;unknown&lt;/code&gt; are both TypeScript types that can hold a value of any shape. The difference is what TypeScript does with that value afterward: &lt;code&gt;any&lt;/code&gt; turns off type checking for it entirely, while &lt;code&gt;unknown&lt;/code&gt; requires you to narrow it, prove what it actually is, before you're allowed to use it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;any&lt;/code&gt; tells TypeScript: don't check this value, I know what I'm doing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;foo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;baz&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript won't say a word about this. JavaScript will still crash at runtime because a string doesn't have &lt;code&gt;.foo.bar.baz()&lt;/code&gt;, TypeScript just isn't watching anymore once something is typed &lt;code&gt;any&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unknown&lt;/code&gt; takes the opposite position: I don't know what this is yet, so prove it before you use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't actually know that the server returned what you expected, just because the request succeeded. Typing this &lt;code&gt;any&lt;/code&gt; tells TypeScript to trust it completely. Typing it &lt;code&gt;unknown&lt;/code&gt; forces you to check before you can touch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once narrowed like this, TypeScript trusts you again, but only inside that branch, and only as far as what you actually checked for.&lt;/p&gt;

&lt;p&gt;This matters most at boundaries you don't control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your TypeScript application
          ↓
     External API
          ↓
        JSON
          ↓
       unknown
          ↓
   Validate or narrow
          ↓
     Trusted data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API responses, user input, third-party libraries, anything read from a database or a file, none of it should be trusted at the type level just because it compiled. &lt;code&gt;unknown&lt;/code&gt; forces the check. &lt;code&gt;any&lt;/code&gt; lets you skip it and find out at runtime instead, usually in production, usually at a worse time than during development.&lt;/p&gt;

&lt;p&gt;The short version worth remembering: &lt;code&gt;any&lt;/code&gt; says trust me, TypeScript stops protecting you. &lt;code&gt;unknown&lt;/code&gt; says I don't know yet, TypeScript makes you prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generics in TypeScript
&lt;/h2&gt;

&lt;p&gt;Generics let you write a function, class, or interface that works with more than one type while still keeping full type information, using a placeholder type, conventionally written &lt;code&gt;T&lt;/code&gt;, that gets filled in with a real type at the point where the code is actually used.&lt;/p&gt;

&lt;p&gt;The idea behind generics is simpler than the syntax makes it look: write code once, and let it preserve whatever specific type it's handed, instead of losing that information.&lt;/p&gt;

&lt;p&gt;Without generics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;result&lt;/code&gt; is typed &lt;code&gt;any&lt;/code&gt; here. The function works, but you've thrown away the fact that you started with an array of numbers.&lt;/p&gt;

&lt;p&gt;With a generic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// T = number&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Allen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;John&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// T = string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;T&amp;gt;&lt;/code&gt; is a placeholder: give me a type, and I'll use that exact type everywhere I've written &lt;code&gt;T&lt;/code&gt;. TypeScript figures out what &lt;code&gt;T&lt;/code&gt; is from what you actually pass in, and carries that information through to the return type.&lt;/p&gt;

&lt;p&gt;The most common real-world shape of this is an API response wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ApiResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ApiResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Allen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ApiResponse&amp;lt;User&amp;gt;&lt;/code&gt;, &lt;code&gt;ApiResponse&amp;lt;Product&amp;gt;&lt;/code&gt;, and &lt;code&gt;ApiResponse&amp;lt;Order&amp;gt;&lt;/code&gt; all reuse the same interface, fully typed, instead of writing three nearly identical ones.&lt;/p&gt;

&lt;p&gt;The same idea shows up just as often on the frontend, in a reusable table or list component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataTable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DataTable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same component, same rendering logic, but each usage stays fully typed to the data it's actually given. This is why generics show up everywhere in API clients, reusable components, and utility functions in general; they're the mechanism that keeps one piece of code reusable without becoming untyped in the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this connects
&lt;/h2&gt;

&lt;p&gt;None of these ideas are actually separate from each other. &lt;code&gt;const&lt;/code&gt; and &lt;code&gt;let&lt;/code&gt; are about being explicit regarding what's allowed to change. Closures are about a function remembering the scope it was created in, even after that scope has technically finished running. &lt;code&gt;Promise.all&lt;/code&gt; and &lt;code&gt;Promise.allSettled&lt;/code&gt; are both about running independent work concurrently; they just disagree on what to do when one piece fails. The event loop underneath all of it decides the actual order any of this code runs in: synchronous first, then every microtask, then the task queue. &lt;code&gt;unknown&lt;/code&gt; exists because you sometimes genuinely don't know a type yet and need to prove it before using it. Generics exist because you often do know the type at the call site and don't want to throw that information away by writing something that treats everything as &lt;code&gt;any&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the pattern worth carrying into the next post in this series: data structures, algorithms, and testing as they actually show up in production, not as isolated interview trivia.&lt;/p&gt;

&lt;p&gt;If there's a concept here you'd explain differently, or a production example you think makes any of this land better, I'd like to hear it. Reach me at &lt;a href="mailto:allen@formgrid.dev"&gt;allen@formgrid.dev&lt;/a&gt;.&lt;/p&gt;




</description>
      <category>javascript</category>
      <category>interview</category>
      <category>typescript</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>NestJS for Express Developers: A Practical Guide, With Prisma ORM</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Sat, 05 Sep 2026 22:37:41 +0000</pubDate>
      <link>https://dev.to/allenarduino/nestjs-for-express-developers-a-practical-guide-with-prisma-orm-537k</link>
      <guid>https://dev.to/allenarduino/nestjs-for-express-developers-a-practical-guide-with-prisma-orm-537k</guid>
      <description>&lt;p&gt;I've spent years building backends in Express. NestJS looks different on the surface, but here's the thing nobody tells you clearly enough: if you already know Express, you don't need to learn backend development again. You need to learn how NestJS organizes the things you already know how to do&lt;/p&gt;

&lt;p&gt;At first, NestJS looks like a lot more ceremony for the same result. A simple route handler turns into a controller class, a service class, decorators everywhere, and a module wiring them together. But once you understand why each piece exists, none of it feels like ceremony anymore; it feels like structure you were probably improvising by hand in every Express project anyway.&lt;/p&gt;

&lt;p&gt;This post is the explanation I wish I'd had on day one, mapped directly against Express, using Prisma instead of TypeORM, since that's the ORM I already use in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-sentence version
&lt;/h2&gt;

&lt;p&gt;Think of NestJS as:&lt;/p&gt;

&lt;p&gt;Express, plus TypeScript, plus dependency injection, plus modules, plus decorators, plus enforced architecture.&lt;/p&gt;

&lt;p&gt;In Express, you might write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same idea in NestJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateUserDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More characters, same idea. The rest of this post is about why that extra structure exists and when it earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project structure
&lt;/h2&gt;

&lt;p&gt;Scaffold a new project, and you'll get something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nest-api/
├── src/
│   ├── app.controller.ts
│   ├── app.controller.spec.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test/
├── package.json
├── tsconfig.json
└── nest-cli.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four files matter to start: &lt;code&gt;main.ts&lt;/code&gt;, &lt;code&gt;app.module.ts&lt;/code&gt;, &lt;code&gt;app.controller.ts&lt;/code&gt;, &lt;code&gt;app.service.ts&lt;/code&gt;. Everything else is scaffolding you'll grow into.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;main.ts&lt;/code&gt; is your entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NestFactory&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@nestjs/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AppModule&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./app.module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;NestFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're coming from Express, this is your &lt;code&gt;const app = express(); app.listen(3000);&lt;/code&gt;, just bootstrapped from a root module instead of a bare instance. &lt;code&gt;NestFactory.create(AppModule)&lt;/code&gt; is Nest reading your entire application's shape from one root module and building it from there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app.module.ts&lt;/code&gt; is the first genuinely unfamiliar concept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;AppController&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;AppService&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hold off on fully understanding modules yet; they'll make more sense once you've seen a controller and a service in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                HTTP Request
                     │
                     ▼
              ┌─────────────┐
              │   Module    │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │ Controller  │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │   Service   │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │   Prisma    │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │ PostgreSQL  │
              └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pieces worth knowing, roughly in the order you'll actually meet them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module&lt;/strong&gt;: organizes a feature&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt;: handles HTTP requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service&lt;/strong&gt;: contains business logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DTO&lt;/strong&gt;: describes and validates incoming data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard&lt;/strong&gt;: controls authentication and authorization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipe&lt;/strong&gt;: validates or transforms input&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interceptor&lt;/strong&gt;: wraps or transforms request and response behavior&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware&lt;/strong&gt;: the same concept as Express middleware&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency injection&lt;/strong&gt;: Nest supplies each class the dependencies it needs, instead of you constructing them by hand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't try to hold all of these at once. Controllers and services get you most of the way there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Express knowledge maps directly
&lt;/h2&gt;

&lt;p&gt;This is genuinely the fastest way to internalize it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Express&lt;/th&gt;
&lt;th&gt;NestJS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app.get()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Get()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app.post()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Post()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;req.params&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Param()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;req.body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Body()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;req.query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@Query()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;res.json()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;return&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Middleware&lt;/td&gt;
&lt;td&gt;Middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;Controller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service modules&lt;/td&gt;
&lt;td&gt;Providers and services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual dependency passing&lt;/td&gt;
&lt;td&gt;Dependency injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation middleware&lt;/td&gt;
&lt;td&gt;Pipes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth middleware&lt;/td&gt;
&lt;td&gt;Guards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error middleware&lt;/td&gt;
&lt;td&gt;Exception filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router organization&lt;/td&gt;
&lt;td&gt;Modules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NestJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NestJS version is saying, out loud, in the type system: this class handles &lt;code&gt;/users&lt;/code&gt;, and this method handles &lt;code&gt;GET /users/:id&lt;/code&gt;. That's the whole trade the extra syntax is making: implicit routing conventions become explicit, checkable structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controllers
&lt;/h2&gt;

&lt;p&gt;This is where you'll feel most at home immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the equivalent of &lt;code&gt;router.get('/users', ...)&lt;/code&gt;, &lt;code&gt;router.get('/users/:id', ...)&lt;/code&gt;, &lt;code&gt;router.post('/users', ...)&lt;/code&gt;, and &lt;code&gt;router.delete('/users/:id', ...)&lt;/code&gt;. Nothing conceptually new here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Services, where Nest starts diverging
&lt;/h2&gt;

&lt;p&gt;In Express you might write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In NestJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Injectable()&lt;/code&gt; is the important part. It tells Nest that this class can be managed by Nest's dependency injection system. Your controller then does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's missing: &lt;code&gt;const usersService = new UsersService()&lt;/code&gt; never appears anywhere. Nest creates it and hands it to the controller. That's dependency injection, and it's worth its own section, because it's the concept that actually changes how you think about structuring an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modules
&lt;/h2&gt;

&lt;p&gt;A module groups related functionality into a feature boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
├── users/
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.module.ts
│   └── dto/
├── auth/
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   └── auth.module.ts
└── app.module.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersController&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersModule&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thinking about this against &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid,s&lt;/a&gt; actual feature set is a useful exercise, even without rebuilding it in Nest: auth, users, forms, leads, integrations, webhooks, billing, and notifications would each map cleanly to its own module. That boundary is exactly what makes Nest attractive once a SaaS codebase grows past the size where "everything imports everything" stops being manageable in a flat Express app.&lt;/p&gt;

&lt;h2&gt;
  
  
  DTOs and validation
&lt;/h2&gt;

&lt;p&gt;Instead of the shapeless &lt;code&gt;@Body() body: any&lt;/code&gt;, you define what's actually expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with &lt;code&gt;class-validator&lt;/code&gt;, this becomes real, enforced validation instead of a type hint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MinLength&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;MinLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nest validates incoming requests against this automatically, which replaces a fair amount of hand-rolled validation middleware you'd otherwise be writing and maintaining yourself in Express.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipes
&lt;/h2&gt;

&lt;p&gt;Pipes transform and validate input before it reaches your handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ParseIntPipe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A URL parameter arrives as the string &lt;code&gt;"123"&lt;/code&gt;. The pipe converts it to the number &lt;code&gt;123&lt;/code&gt; before your method ever sees it, and throws an appropriate exception automatically if the value isn't valid in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exceptions
&lt;/h2&gt;

&lt;p&gt;Express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NestJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nest turns that thrown exception into the correct HTTP response for you. You also get &lt;code&gt;BadRequestException&lt;/code&gt;, &lt;code&gt;UnauthorizedException&lt;/code&gt;, &lt;code&gt;ForbiddenException&lt;/code&gt;, &lt;code&gt;ConflictException&lt;/code&gt;, and &lt;code&gt;InternalServerErrorException&lt;/code&gt; out of the box, all consistent, all readable at the point they're thrown instead of buried in a response call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guards
&lt;/h2&gt;

&lt;p&gt;You'll meet these implementing authentication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request either fails the guard and never reaches the controller, or passes it and proceeds normally. That's generally cleaner than scattering an authentication check inside every individual Express route handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interceptors
&lt;/h2&gt;

&lt;p&gt;These can wait until you actually need them. They wrap around a controller's execution, useful for logging, response transformation, timing, or caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoggingInterceptor&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestInterceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CallHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Request started&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Middleware
&lt;/h2&gt;

&lt;p&gt;Good news here: you already know this one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoggerMiddleware&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestMiddleware&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This transfers from Express almost without translation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring up Prisma instead of TypeORM
&lt;/h2&gt;

&lt;p&gt;Most NestJS material defaults to TypeORM, but if you're already running Prisma, there's no reason to switch ORMs just to learn a framework. A thin &lt;code&gt;PrismaService&lt;/code&gt; is all it takes to bring Prisma into Nest's dependency injection system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PrismaService&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;PrismaClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onModuleInit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your service depends on it like anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PrismaService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the module wires both together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersController&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PrismaService&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nest handles the application's structure and dependency graph. Prisma handles the database layer. Neither one needs to know much about the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding dependency injection: why @Injectable() actually exists
&lt;/h2&gt;

&lt;p&gt;This is the concept that took me the longest to actually feel, rather than just memorize, so it's worth its own deep section.&lt;/p&gt;

&lt;p&gt;Here's the honest question: Express works completely fine without anything like &lt;code&gt;@Injectable()&lt;/code&gt;. So why does Nest need it?&lt;/p&gt;

&lt;h3&gt;
  
  
  Express: you are the dependency injection system
&lt;/h3&gt;

&lt;p&gt;Suppose you have this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sending email...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;emailService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EmailService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Express, you wire this by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usersController&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;usersController&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;@Injectable()&lt;/code&gt; anywhere, because none is needed. You are the dependency injection system: you decide what gets created, in what order, and what gets passed into what. TypeScript's constructors already support this pattern natively; nothing about it is Nest-specific.&lt;/p&gt;

&lt;h3&gt;
  
  
  What NestJS actually adds
&lt;/h3&gt;

&lt;p&gt;NestJS's pitch is essentially: why keep wiring this by hand? So instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sending email...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;emailService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EmailService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersController&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;EmailService&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now nothing gets manually constructed. Nest resolves the graph itself: the controller needs &lt;code&gt;UsersService&lt;/code&gt;, so Nest finds or creates one; that service needs &lt;code&gt;EmailService&lt;/code&gt;, so Nest finds or creates that too. &lt;code&gt;@Injectable()&lt;/code&gt; is the marker that tells Nest's container "this class is allowed to participate in that resolution," nothing more mystical than that. It isn't a requirement of TypeScript constructors; it's metadata Nest specifically needs to do its job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters more as the app grows
&lt;/h3&gt;

&lt;p&gt;At three classes, manual wiring in Express is completely fine, arguably even clearer, since you can see the whole graph in one place. The value of letting Nest do it shows up once the graph gets genuinely large.&lt;/p&gt;

&lt;p&gt;Picture something the shape of Formgrid's dependency tree, hypothetically, if it were structured this way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UsersController
      │
      ├── UsersService
      │        │
      │        ├── UsersRepository → DatabaseService
      │        ├── EmailService → ResendService
      │        └── PaymentService → PaddleService
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wired by hand in Express, that's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DatabaseService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UsersRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ResendService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paddle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaymentService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paddle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's manageable once. It gets genuinely unpleasant at real scale, a hundred services deep, where any one of them needing an additional dependency means updating every place that constructs it, in the right order, every time.&lt;/p&gt;

&lt;p&gt;In Nest, you just declare what each class needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EmailService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the container resolves and constructs the entire tree for you, in the correct order, every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual insight
&lt;/h3&gt;

&lt;p&gt;Constructors aren't the difference between Express and NestJS. Both let you declare &lt;code&gt;constructor(private emailService: EmailService)&lt;/code&gt; and both will happily accept it; that part is plain TypeScript, nothing framework-specific about it either way.&lt;/p&gt;

&lt;p&gt;The difference is who is responsible for calling those constructors and supplying what they need. In Express, that's you, by hand, every time. In NestJS, that's the container, once you've told it, via &lt;code&gt;@Injectable()&lt;/code&gt; and a module's &lt;code&gt;providers&lt;/code&gt; array, which classes it's allowed to manage.&lt;/p&gt;

&lt;p&gt;That's the whole idea &lt;code&gt;@Injectable()&lt;/code&gt; exists to express. Everything else in Nest, controllers, modules, guards, pipes, is really just structure built on top of that one core mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;If you already know Express well, none of this is new backend knowledge; it's a new way of organizing backend knowledge you already have. Controllers are routers with better names. Services are the business logic you were already separating, formalized. Modules are the feature folders you were probably already creating by convention, now enforced by the framework instead of by team discipline.&lt;/p&gt;

&lt;p&gt;The place NestJS earns its extra ceremony is scale and team size; a large, long-lived API with many contributors benefits from a framework insisting on structure. A small API, a quick internal tool, or a solo project where you already have the discipline to keep things organized may not need it at all. Express isn't wrong for those; it's just a different tradeoff.&lt;/p&gt;

&lt;p&gt;If you've made the same jump from Express to NestJS, or gone the other way, I'd like to hear what actually clicked for you. Reach me at &lt;a href="mailto:allen@formgrid.dev"&gt;allen@formgrid.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Allen, a full-stack TypeScript engineer and the founder of &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt; and &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;SheetRocket&lt;/a&gt;. I write about real production engineering from products that people actually pay for. More at &lt;a href="https://jonesstack.com" rel="noopener noreferrer"&gt;jonesstack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>express</category>
      <category>node</category>
      <category>backend</category>
    </item>
    <item>
      <title>I Built a Next.js Auth Boilerplate That Saves Developers Two to Four Weeks of Setup Time</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Tue, 01 Sep 2026 20:11:29 +0000</pubDate>
      <link>https://dev.to/allenarduino/i-built-a-nextjs-auth-boilerplate-that-saves-developers-two-to-four-weeks-of-setup-time-2m3j</link>
      <guid>https://dev.to/allenarduino/i-built-a-nextjs-auth-boilerplate-that-saves-developers-two-to-four-weeks-of-setup-time-2m3j</guid>
      <description>&lt;p&gt;Every time I started a new project, whether it was Formgrid, Sheetrocket, or a client build, the first week or two disappeared into authentication. Email verification tokens. Password reset flows. NextAuth configuration. A Prisma schema for users and sessions. Setting up the Google OAuth consent screen. Middleware for protected routes.&lt;/p&gt;

&lt;p&gt;None of that is intellectually interesting. All of it is necessary. And it has to be done correctly, because a mistake in auth isn't a bug; it's a security vulnerability with someone's real account attached to it.&lt;/p&gt;

&lt;p&gt;After doing this properly from scratch for the fifth time, I stopped. I built it once, the right way, open-sourced it, and made it the starting point for every project after this one.&lt;/p&gt;

&lt;p&gt;The result is a production-ready authentication starter kit that gets a new Next.js project from zero to working auth in minutes instead of weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it includes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Email and password authentication with Zod validation.&lt;/strong&gt; Not just a signup form, proper server-side validation that catches weak passwords, duplicate emails, and malformed input before any of it reaches the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email verification.&lt;/strong&gt; New users can't access protected routes until they verify their email, and the verification token expires after 24 hours. Without this, any email address, including ones the signer doesn't actually own, can be used to create an account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Password reset with secure tokens.&lt;/strong&gt; The reset token expires after one hour. Without an expiry, a leaked reset link is a permanent vulnerability sitting in someone's inbox forever. The token itself is hashed before it's stored, so even a full database breach doesn't hand over usable reset tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google OAuth with NextAuth.js v5.&lt;/strong&gt; Handles the OAuth flow, stores the account link in the database, and merges correctly with an existing email account if the user originally signed up with a password. Getting that merge logic wrong is one of the most common auth bugs I've seen in real production apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protected routes with Next.js middleware.&lt;/strong&gt; Everything under the dashboard directory is protected automatically. The middleware checks the session and redirects unauthenticated users to login before the page component ever renders; no manual auth check copied into every page.&lt;/p&gt;

&lt;p&gt;** Role-based access.** The User model has a role field, defaulting to USER, with support for ADMIN and any other role you want to add. Middleware can gate admin-only routes without touching every protected page individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A responsive dashboard layout in Tailwind.&lt;/strong&gt; A clean starting point for the authenticated UI, so you can start building your actual feature immediately after cloning instead of also designing a dashboard shell first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, and the honest tradeoffs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Next.js 15 with the App Router.&lt;/strong&gt; The current standard for React apps with server components and server actions. The App Router changes how auth is wired up compared to the Pages Router, and NextAuth v5 is built specifically for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prisma with MySQL.&lt;/strong&gt; Type safe queries with automatic migrations. MySQL for wide hosting compatibility, easily swapped for PostgreSQL by changing one line in the Prisma schema and the DATABASE_URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NextAuth.js v5.&lt;/strong&gt; The current major version, with breaking changes from v4. The configuration pattern changed significantly, and most tutorials still floating around online use the old v4 pattern. This boilerplate uses the correct v5 approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resend for transactional email.&lt;/strong&gt; The most developer-friendly email API I've used. It's what Formgrid runs in production for verification and notification emails. The templates in the boilerplate are plain text and easy to reskin with your own branding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailwind CSS.&lt;/strong&gt; Utility-first styling that ships zero unused CSS to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three decisions worth explaining, because most tutorials get them wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  JWT sessions over database sessions
&lt;/h3&gt;

&lt;p&gt;NextAuth supports two session strategies. Database sessions store a session record per login and validate it against the database on every request. JWT sessions encode the session data into a signed token and validate it cryptographically, no database query required.&lt;/p&gt;

&lt;p&gt;Database sessions are more revocable; you can kill a session instantly by deleting its record. JWT sessions are faster, since there's no query on every authenticated request, but you can't invalidate one without extra infrastructure sitting on top. The boilerplate uses JWT for simplicity and speed. If your product needs immediate session revocation, for example, kicking someone out the moment their subscription lapses, that's the tradeoff worth knowing before you commit to this pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hashing verification and reset tokens
&lt;/h3&gt;

&lt;p&gt;Verification tokens and password reset tokens are stored hashed in the database, never in plain text. That means even if someone reads the raw database, they can't use the stored tokens to verify accounts or reset passwords themselves. The plain text token only ever exists in the email link, and it's compared against the hash at submission time. Same principle as password hashing, applied to a temporary token instead of a permanent credential.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auth checks belong in middleware, not in page components
&lt;/h3&gt;

&lt;p&gt;Next.js middleware runs on the Edge runtime before the page ever renders. Putting the auth check there means a protected page is never rendered for an unauthenticated visitor, not even partially. Putting the check inside the page component instead means the page starts rendering and then redirects, which produces a flash of content the user was never supposed to see. Middleware is the correct place for the gate, not a convenience, but a correctness requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:allenarduino/nextjs-prisma-auth-boilerplate.git

&lt;span class="nb"&gt;cd &lt;/span&gt;nextjs-prisma-auth-boilerplate

npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt; and fill in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DATABASE_URL&lt;/code&gt;, your MySQL connection string&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXTAUTH_SECRET&lt;/code&gt;, a random 32-character string; generate one with &lt;code&gt;openssl rand -base64 32&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXTAUTH_URL&lt;/code&gt;, your app's base URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GOOGLE_CLIENT_ID&lt;/code&gt; and &lt;code&gt;GOOGLE_CLIENT_SECRET&lt;/code&gt;, from the Google Cloud Console after creating an OAuth 2.0 credential&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_API_KEY&lt;/code&gt;, from resend.com after creating an account and verifying a sending domain&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EMAIL_FROM&lt;/code&gt;, the sender address your verification and reset emails go out from, for example &lt;code&gt;Formgrid &amp;lt;no-reply@yourdomain.com&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx prisma generate

npx prisma db push

npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole setup. From clone to a working signup, verification, login, and password reset flow, in minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to build on top of it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A SaaS product.&lt;/strong&gt; The auth layer is done. Add your subscription logic, your core features, and your billing integration. The User model's role field is already there to extend into a subscription tier if you need one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A client project.&lt;/strong&gt; Clone it, configure the environment variables for the client's domain and database, restyle the Tailwind to match their brand, and deploy. The auth is production-ready from day one, not a prototype you'll need to harden later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A side project.&lt;/strong&gt; Stop spending the first two weeks on auth and start building the part that's actually yours. The boilerplate handles the foundation so you can get to the feature that makes your project worth building in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this connects to Formgrid
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt; runs a similar auth pattern, built in Express and Prisma instead of NextAuth, but the same principles apply underneath: email verification, hashed tokens, JWT sessions, Google OAuth, protected routes. Building this boilerplate was really just distilling those same patterns, ones already proven in a product real customers pay for, into a shareable starting point for the Next.js ecosystem specifically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands right now
&lt;/h2&gt;

&lt;p&gt;The repo has picked up 17 GitHub stars and 9 forks in a short amount of time, which is a decent early signal that the pain point is real and not just mine. You can browse the code at &lt;a href="https://github.com/allenarduino/nextjs-prisma-auth-boilerplate" rel="noopener noreferrer"&gt;github.com/allenarduino/nextjs-prisma-auth-boilerplate&lt;/a&gt; or try the live demo at &lt;a href="https://nextjs-prisma-auth-boilerplate.vercel.app" rel="noopener noreferrer"&gt;nextjs-prisma-auth-boilerplate.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Authentication is never the interesting part of a product. It's the necessary foundation everything else gets built on. Getting it right matters because getting it wrong means real security vulnerabilities attached to real users. Getting it done quickly matters because every week spent on auth is a week not spent on the feature that actually makes your product worth using.&lt;/p&gt;

&lt;p&gt;Clone the repo, configure the environment, and go build the interesting part.&lt;/p&gt;

&lt;p&gt;If you use it on a project, or find a way to improve it, I'd genuinely like to hear about it. The repo is open for contributions and issues at &lt;a href="https://github.com/allenarduino/nextjs-prisma-auth-boilerplate" rel="noopener noreferrer"&gt;github.com/allenarduino/nextjs-prisma-auth-boilerplate&lt;/a&gt;. Reach me at &lt;a href="mailto:allen@formgrid.dev"&gt;allen@formgrid.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Allen, a full-stack TypeScript engineer and the founder of &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt; and &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;Sheetrocket&lt;/a&gt;. I write about real production engineering from products that people actually pay for. More at &lt;a href="https://jonesstack.com" rel="noopener noreferrer"&gt;jonesstack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>nextjs</category>
      <category>nextauth</category>
    </item>
    <item>
      <title>I Deployed a Backend API on Vercel and Here Is What Broke</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:19:12 +0000</pubDate>
      <link>https://dev.to/allenarduino/i-deployed-a-backend-api-on-vercel-and-here-is-what-broke-29e5</link>
      <guid>https://dev.to/allenarduino/i-deployed-a-backend-api-on-vercel-and-here-is-what-broke-29e5</guid>
      <description>&lt;p&gt;The backend was for one of my&lt;br&gt;
projects, &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;Sheetrocket&lt;/a&gt;, a tool that turns any Google Sheet into a REST API or a set of no-code embeddable widgets. &lt;/p&gt;

&lt;p&gt;The idea is simple: developers connect&lt;br&gt;
a Google Sheet and get a REST endpoint that returns their&lt;br&gt;
data as JSON, while non-technical users display that same data&lt;br&gt;
on any website as cards, catalogues, or tables without writing a single line of code. Update the sheet, and everything stays in sync automatically.&lt;/p&gt;

&lt;p&gt;The problem is that Google Sheets enforces rate limits on API requests. If every request to a Sheetrocket endpoint triggered a fresh call to the Google Sheets API, I would hit those limits fast, especially under any real traffic. The obvious solution was in-memory caching: fetch the sheet data once, store it in a &lt;code&gt;Map&lt;/code&gt;, return the cached version on subsequent requests, and only refresh from Google when the cache expired.&lt;/p&gt;

&lt;p&gt;I had built exactly this pattern before, on long-running servers, without any issues. The cache sat at the module level, warmed up on the first request, and served every request after that from memory. Fast, simple, and it kept Google Sheets API calls to a minimum.&lt;/p&gt;

&lt;p&gt;Vercel felt like the obvious deployment choice. One-click deploy from GitHub, a generous free tier, and I was already using Next.js for other work. It barely felt like a decision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchFromDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in production, the cache never worked. Every request was hitting the Google Sheets API directly, consuming rate limit quota on every single call. The cache that worked perfectly in local development was silently empty in production every time.&lt;/p&gt;

&lt;p&gt;I spent time assuming it was a bug in my caching logic before I realized the problem had nothing to do with my code at all.&lt;/p&gt;

&lt;p&gt;The problem was not the code. It was where the code was running.&lt;/p&gt;

&lt;p&gt;And the cost of that was not just slower responses; it was correctness. A cache failure here meant real Google Sheets API quota burning on every request instead of most requests getting served from memory. Under any real traffic, that means hitting Google's rate limit, getting throttled, and returning errors instead of sheet data. Visitors don't see a slow widget at that point; they see a broken one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Vercel runs Next.js API routes as serverless functions, not as a traditional server that starts once and stays alive. A serverless function spins up when a request arrives and shuts down when it goes idle.&lt;/p&gt;

&lt;p&gt;My in-memory cache lived inside that function instance. When the instance shut down, the cache went with it. When a new instance started, the cache was empty again. The code correctly populated the cache on the first request, but by the time the next request came in, Vercel may well have spun up a fresh instance with no memory of it at all.&lt;/p&gt;

&lt;p&gt;This is why the same code behaves differently locally versus on Vercel. Locally, the Next.js development server runs as a long-running process. It starts once and stays alive, so the cache persists across requests because the same process handles them all. Everything works perfectly in development. On Vercel, the same code runs as serverless functions. The cache works sometimes, when the same warm instance happens to handle consecutive requests. It fails other times, when a cold instance with an empty cache picks up the request instead. The behavior is inconsistent, and it depends entirely on Vercel's internal instance management, which the developer has no visibility into or control over.&lt;/p&gt;

&lt;p&gt;One sentence covers all of it: Vercel runs API routes as serverless functions, and serverless functions have no guaranteed persistent memory between requests. An in-memory cache assumes the same process handles every request. On Vercel, that assumption is simply wrong.&lt;/p&gt;

&lt;p&gt;Before I go further: Vercel has genuinely improved here. Fluid compute now keeps functions warm between requests for longer and reduces cold starts meaningfully compared to a couple of years ago. This isn't a case against Vercel as a platform. It's a case about matching an execution model to a problem, and a pure JSON API with in-memory state and no rendering is not the problem serverless was built to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else that breaks, for the same reason
&lt;/h2&gt;

&lt;p&gt;Every problem in this post traces back to one root cause: serverless functions are stateless, and in-memory state requires a stateful process to hold it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate limiting
&lt;/h3&gt;

&lt;p&gt;A common pattern tracks how many requests an IP address has made in a rolling window, using an in-memory counter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestCounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isRateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requestCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;requestCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a long-running server, this works exactly as written. On serverless, each function instance keeps its own counter. Ten concurrent requests might land on ten different instances, each with a counter showing one request. A limit meant to be ten requests per minute is effectively multiplied by however many instances Vercel has spun up. The rate limiter isn't broken loudly; it's broken silently, which is worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold start latency
&lt;/h3&gt;

&lt;p&gt;When a function has been idle, Vercel has to spin up a new instance: load the Node.js runtime, import every module, establish any connections, and only then handle the request. For a simple always-on API expected to respond in milliseconds, that adds latency that is not just slower; it's unpredictable, shifting based on traffic patterns rather than staying consistent.&lt;/p&gt;

&lt;p&gt;For an internal tool used sporadically through the day, this means the first person to use it after a quiet stretch always waits longer than everyone after them. For a customer-facing API, it's the opposite of what you'd want: the slowest responses land on whoever arrives after the longest gap, often the exact users you'd least want to leave with a bad first impression.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database connection overhead
&lt;/h3&gt;

&lt;p&gt;A long-running server maintains a connection pool to the database once, at startup. A pool of ten connections can handle thousands of requests a second by reusing those same ten connections over and over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A serverless function doesn't get that luxury. Each cold start either opens a fresh connection or competes with other invocations over a shared pool. If a traffic spike causes Vercel to spin up 50 instances and each one opens its own connection, you've consumed 50 of your available database connections almost instantly. PostgreSQL's free and small paid tiers often cap connections somewhere between 25 and 100. The 51st instance simply can't connect, and that request fails, not because of load on the database itself, but because of how many separate processes are all trying to reach it at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Background jobs
&lt;/h3&gt;

&lt;p&gt;If you want to kick off a background task when a request arrives and let it keep running after the response is sent, Vercel will kill that task the moment the function shuts down. Fire-and-forget patterns that work cleanly on a long-running server don't work on serverless without extra infrastructure to keep the job alive independently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt; relies on exactly this pattern for four background operations after every form submission: Google Sheets sync, Notion sync, AI analysis, and the email notification itself. None of them block the HTTP response the visitor sees, and all of them keep running after that response is sent. That only works because Formgrid runs on a long-running Express server on Render, where the process stays alive long enough to actually finish the background work instead of being torn down mid-task.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Vercel and serverless are genuinely the right call
&lt;/h2&gt;

&lt;p&gt;This isn't an argument that serverless is bad; it's specific to what it's optimized for. Serverless is a strong choice for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static sites and marketing pages, where there's no state to maintain&lt;/li&gt;
&lt;li&gt;Server-side rendered pages with Next.js, where rendering happens fresh per request anyway&lt;/li&gt;
&lt;li&gt;Edge functions serving geographically distributed, low-latency responses&lt;/li&gt;
&lt;li&gt;Webhooks and event-driven functions that run occasionally, not continuously, with no state requirements&lt;/li&gt;
&lt;li&gt;Bursty, unpredictable workloads where paying per request beats paying for idle time&lt;/li&gt;
&lt;li&gt;Rapid prototyping, where deployment simplicity matters more than performance tuning&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A decision framework
&lt;/h2&gt;

&lt;p&gt;A short set of questions, each pointing toward an answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does your API maintain state between requests, caches, counters, session data?&lt;/strong&gt; Yes: long-running server. No: either works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it hold open connections to a database or external service that are expensive to establish?&lt;/strong&gt; Yes: long-running server with connection pooling. No: either works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you need consistent sub-100ms response times regardless of traffic patterns?&lt;/strong&gt; Yes: long-running server. No: serverless is acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you need background processing that continues after the response is sent?&lt;/strong&gt; Yes: long-running server. No: either works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is your traffic bursty and unpredictable, and is paying per request more important than consistent latency?&lt;/strong&gt; Yes: serverless is the better economic choice. No: a long-running server is usually more cost-effective and predictable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this a Next.js app with both a frontend and API routes, where the frontend genuinely benefits from Vercel's CDN and deploy experience?&lt;/strong&gt; Yes: Vercel makes sense; the frontend benefits usually outweigh the API route limitations for most apps. No, pure API only: a long-running server on Render or Railway is almost always the better choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform comparison, with real costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Render&lt;/strong&gt;: the free tier spins down after inactivity, which reintroduces the same cold start problem you're trying to escape. The paid tier, $7 a month, keeps the server always on with consistent performance. Formgrid runs on a $7 a month Render instance serving around 1,000 daily visitors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway&lt;/strong&gt;: a free tier with a $5 monthly credit, then usage-based, roughly $5 to $10 a month for a small always-on API. Good developer experience, flexible for more complex setups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fly.io&lt;/strong&gt;: a free tier is available, runs containers globally, a solid option for geographic distribution without full serverless complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hetzner&lt;/strong&gt;: around 4 euros a month for a small VPS with 2GB of RAM. The cheapest option if you're comfortable managing your own server, full control, no abstraction layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DigitalOcean App Platform&lt;/strong&gt;: a free tier available, managed containers, an experience fairly similar to Render.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid approach
&lt;/h2&gt;

&lt;p&gt;Plenty of real applications use both, and it's worth naming because it's the actual nuance most take on this miss. A Next.js frontend on Vercel handles the marketing site, while a separate, long-running Express API on Render handles the backend that needs connection pooling and background processing. Vercel does what it's built for. Render does what it's built for.&lt;/p&gt;

&lt;p&gt;Formgrid is built this way. The API is a long-running Express server on Render. The dashboard is a React SPA. They're separate services, deployed separately, because each one has genuinely different infrastructure requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The infrastructure decision you make at the start of a project is one of the hardest to undo later. Not because the migration itself is usually technically complex; it typically isn't. But because you have to do it without anyone noticing.&lt;/p&gt;

&lt;p&gt;Understanding what serverless is actually optimized for, before reaching for it because it feels familiar, is one of the most valuable habits you can build as an engineer. Not because serverless is wrong. Because the right tool for the right job beats the familiar tool for every job, every time.&lt;/p&gt;

&lt;p&gt;If you've hit this same wall, or made the same call and lived to tell about it, I'd genuinely like to hear what you learned. Reach me at &lt;a href="mailto:allen@formgrid.dev"&gt;allen@formgrid.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Allen, a full-stack TypeScript engineer and the founder of &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;SheetRocket&lt;/a&gt;, a tool that turns Google Sheets into REST APIs and embeddable widgets, and &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt;, an open-source form backend and lead pipeline. Both run on long-running Express servers on Render. I write from what actually happens in production, not from theory. More at &lt;a href="https://jonesstack.com" rel="noopener noreferrer"&gt;jonesstack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>backend</category>
      <category>infrastructure</category>
      <category>node</category>
    </item>
    <item>
      <title>How to Send Form Submissions to Notion Automatically Without Zapier</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Sat, 29 Aug 2026 22:59:12 +0000</pubDate>
      <link>https://dev.to/allenarduino/how-to-send-form-submissionsto-notion-automatically-withoutzapier-56co</link>
      <guid>https://dev.to/allenarduino/how-to-send-form-submissionsto-notion-automatically-withoutzapier-56co</guid>
      <description>&lt;p&gt;If your team lives in Notion, your website contact form probably does not fit into that workflow.&lt;/p&gt;

&lt;p&gt;A visitor fills out your form. The submission lands in an email inbox somewhere, separate from the Notion workspace where your team actually manages clients, projects, and leads. Someone has to notice the email, copy the details, and paste them into Notion by hand. On a busy week, that step gets skipped. The enquiry sits in an inbox nobody checks until it is too late to reply.&lt;/p&gt;

&lt;p&gt;Moving data from email into Notion manually does not scale, and it is exactly the kind of small, repeated task that quietly costs you leads.&lt;/p&gt;

&lt;p&gt;This post walks through how to send form submissions straight into a Notion database automatically, with no Zapier account, no custom code, and no manual copying, using Formgrid's native Notion integration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Short Version
&lt;/h2&gt;

&lt;p&gt;Skip Zapier and custom webhook code entirely. Create your form, connect your Notion workspace once through Notion's own authorization screen, choose which database submissions should go into, and every new entry appears as a page automatically. Properties are created for you from your form fields on the first submission.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://formgrid.dev/app/signup" rel="noopener noreferrer"&gt;Try it free at formgrid.dev&lt;/a&gt;, or keep reading for the full walkthrough with screenshots.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Is Harder Than It Should Be
&lt;/h2&gt;

&lt;p&gt;If you have looked into connecting a form to Notion before, you have probably run into one of these:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zapier.&lt;/strong&gt; It works, but it costs $20 or more per month on top of whatever you already pay for your form tool. You also have to build the Zap yourself, and Zaps break quietly when either tool changes something on their end. There is also a delay between the form submission and the Notion entry showing up, since Zapier checks for new data on a schedule rather than instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual export.&lt;/strong&gt; Download a CSV of your submissions and import it into Notion. This works exactly once, then becomes a task you have to remember to repeat. It is never real-time, and it is the first thing to get skipped when you are busy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notion integrations built into some form tools.&lt;/strong&gt; A few form builders offer a native Notion connection, but only for forms built inside that tool. If you already have an HTML form on your website, or a form built somewhere else, those integrations are not an option for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building it yourself.&lt;/strong&gt; Writing a webhook handler that receives your form submission and inserts it into Notion through their API is a real option if you can code, but it means hosting backend code, handling authentication with Notion, and maintaining it going forward. That is a lot of ongoing work for something that should be a checkbox.&lt;/p&gt;

&lt;p&gt;None of these are good defaults for a team that just wants their form submissions to show up in Notion the moment someone submits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;This guide is for anyone who manages their work in Notion and wants form submissions to land there automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agencies and freelancers&lt;/strong&gt; who track clients and projects in Notion and want new enquiries to appear as a page they can move into their existing workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small teams&lt;/strong&gt; who use a Notion database as their shared source of truth and do not want leads sitting in an inbox separate from everything else they manage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Course creators and community builders&lt;/strong&gt; who run their whole operation out of Notion and want enrollment or membership enquiries to show up right alongside the rest of their workspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone replacing a Zapier setup&lt;/strong&gt; who wants the same result without the extra monthly cost or the Zap that needs maintaining.&lt;/p&gt;

&lt;p&gt;If your team already lives in Notion, this integration was built for that exact workflow.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Simple Alternative
&lt;/h2&gt;

&lt;p&gt;Formgrid, an open-source form backend and no-code form builder, now has a native Notion integration built directly into the platform.&lt;/p&gt;

&lt;p&gt;You connect your Notion workspace once, using Notion's own authorization screen, the same one you have seen when connecting other tools to Notion. From that point on, every form submission automatically creates a new page in the Notion database you choose. No Zapier account, no webhook to write, no manual export.&lt;/p&gt;

&lt;p&gt;It works whether you build your form using Formgrid's no-code form builder or you already have an existing HTML form on your website pointed at a Formgrid endpoint. Either way, the moment someone submits, the same submission that triggers your email notification also creates a page in Notion, in real time.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Notion Databases and Pages Work, in Plain Terms
&lt;/h2&gt;

&lt;p&gt;Before the walkthrough, here is the short version of what you are actually setting up.&lt;/p&gt;

&lt;p&gt;In Notion, a database is like a spreadsheet. Each row in that spreadsheet is called a page, and you can open any page to read the full details behind it. Each column in the spreadsheet is called a property, things like Name, Email, or Message.&lt;/p&gt;

&lt;p&gt;When a form is submitted, Formgrid creates one new page in your chosen database, essentially adding one new row. Each field from your form becomes a property on that page: your Name field becomes a Name property, your Email field becomes an Email property, and so on. The submission time is added automatically as a property called Submitted At.&lt;/p&gt;

&lt;p&gt;You do not need to set any of this up manually. The properties are created automatically the first time a submission comes in, matched to your form's fields.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step by Step: Connecting Formgrid to Notion
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Sign Up at Formgrid
&lt;/h3&gt;

&lt;p&gt;Head to &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;formgrid.dev&lt;/a&gt; and sign up using Google or email. No credit card required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2i7kk8wu52ink40h5xed.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2i7kk8wu52ink40h5xed.png" alt="Screenshot: Formgrid landing page" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiv8p690iozkevgnxgcm2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiv8p690iozkevgnxgcm2.png" alt="Screenshot: Formgrid signup page" width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create Your Form
&lt;/h3&gt;

&lt;p&gt;Once you are logged in, you will land on your dashboard. Click New Form to get started.&lt;/p&gt;

&lt;p&gt;You can either build a form from scratch using Formgrid's drag-and-drop form builder, or, if you already have an existing HTML form on your website, create a form and use the endpoint URL it gives you instead. Both work the same way with the Notion integration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6gtay1d12ykduo2ssx55.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6gtay1d12ykduo2ssx55.png" alt="Screenshot: Formgrid dashboard showing the New Form button and a created form" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8t5250nh8gdoml4pjsyn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8t5250nh8gdoml4pjsyn.png" alt="Screenshot: Formgrid form details page showing the overview tab" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Open the Integrations Tab
&lt;/h3&gt;

&lt;p&gt;Click into your form to open its details page, then click the Integrations tab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftedbiwapi19wxn1o3y0q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftedbiwapi19wxn1o3y0q.png" alt="Screenshot: Formgrid form details page with the Integrations tab highlighted" width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Find the Notion Section
&lt;/h3&gt;

&lt;p&gt;Scroll down to the Notion integration section and click Connect with Notion.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtbm80sfe52866cndw9a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtbm80sfe52866cndw9a.png" alt="Screenshot: Notion integration section in the Integrations tab, showing the Connect with Notion button" width="798" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Authorize Formgrid in Notion
&lt;/h3&gt;

&lt;p&gt;You are redirected to Notion's own authorization screen. This is Notion's official page, not a Formgrid page, so it looks exactly like the consent screen you have seen when connecting any other tool to Notion. Choose the workspace and the pages or databases you want to share with Formgrid, then confirm.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtmtnry3gkz0x7vxwr8w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtmtnry3gkz0x7vxwr8w.png" alt="Screenshot: Notion's official OAuth consent screen" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Choose Your Database
&lt;/h3&gt;

&lt;p&gt;Back in Formgrid, you will see a dropdown listing every Notion database you shared with the integration in the previous step. Select the one where you want form submissions to appear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foxeo6e1hc3o9p66crkh8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foxeo6e1hc3o9p66crkh8.png" alt="Screenshot: Formgrid database picker dropdown showing available Notion databases" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Confirm the Connection
&lt;/h3&gt;

&lt;p&gt;Click Connect. The Integrations tab now shows a connected state, including the name of the Notion database you selected, along with the option to disconnect at any time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexu35q3n8ylrrjjgyu3w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexu35q3n8ylrrjjgyu3w.png" alt="First Screenshot: Formgrid Integrations tab showing a connected Notion database with the disconnect option" width="800" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbs60uccavo16ij83ixbi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbs60uccavo16ij83ixbi.png" alt="Second Screenshot: Formgrid Integrations tab showing a connected Notion database with the disconnect option" width="799" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Test It
&lt;/h3&gt;

&lt;p&gt;Fill out your form yourself, either on your live website or through the Formgrid shareable link. Within moments, three things happen at once, automatically, with no extra setup on your part.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9hlhg0s3jdv3abfs9ew5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9hlhg0s3jdv3abfs9ew5.png" alt="Screenshot showing form with inputs filled" width="800" height="608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You get an email notification instantly, and it is not a generic "new submission" email. &lt;/p&gt;

&lt;p&gt;Formgrid analyzes every submission with AI before the email arrives, so the subject line already tells you what kind of submission it is before you open it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Hot Lead] Contact Form
[Likely Spam] Contact Form
[Sales Pitch] Contact Form
[Support Request] Contact Form
[General Enquiry] Contact Form
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F886bw2axmyd9f1m92rdy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F886bw2axmyd9f1m92rdy.png" alt="Screenshot showing email with general enquiry" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You know what arrived without opening the email. Spam gets deleted in one tap. A hot lead gets opened and answered immediately. The AI also writes a summary inside the email body, so you understand what the person needs without reading through every field yourself.&lt;/p&gt;

&lt;p&gt;At the same time, a new page appears in your connected Notion database with every field from your submission filled in as a property.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qaqagb6wx2mo4ql5xhe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qaqagb6wx2mo4ql5xhe.png" alt="First Screenshot: a Notion database showing a new page created from a form submission, with Name, Email, Message, and Submitted At properties populated" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft11801o96akou123qhkd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft11801o96akou123qhkd.png" alt="Second Screenshot: a Notion database showing a new page created from a form submission, with Name, Email, Message, and Submitted At properties populated" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same submission also becomes a tracked lead in your Formgrid dashboard, starting at New, which you will see in the next section.&lt;/p&gt;

&lt;p&gt;That is the entire setup. From here, every future submission to this form lands in Notion automatically, with no further steps.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Picture: What Happens to Every Submission
&lt;/h2&gt;

&lt;p&gt;It is worth being explicit about all three things that happen simultaneously, since most people searching for a Notion integration are only thinking about the first one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instant email notification with AI analysis.&lt;/strong&gt; Every submission is analyzed before the email reaches your inbox, so the subject line tells you what it is: a hot lead, likely spam, a sales pitch, or a support request, before you open it. A short AI summary inside the email tells you what the person actually needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic Notion database entry.&lt;/strong&gt; At the same moment, a new page is created in your connected database with the full submission as properties, organized in the workspace your team already uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lead pipeline tracking.&lt;/strong&gt; The same submission also becomes a tracked lead inside Formgrid, moving through New, Contacted, and Converted as you work it. You can add a private note, set a follow-up reminder that emails you on the day you need to act, and see your conversion rate over time. If you reply to a lead directly from the Formgrid dashboard using a connected Gmail account, the status updates to Contacted automatically and the reply is logged on that lead's timeline.&lt;/p&gt;

&lt;p&gt;You get the speed of an email, the organization of Notion, and a managed follow-up workflow, all from one submission, with no manual step connecting any of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Notion and Formgrid Work Together
&lt;/h2&gt;

&lt;p&gt;It helps to think of these as two different jobs rather than two tools doing the same thing.&lt;/p&gt;

&lt;p&gt;Notion is where you organize and search your submission data. Filter by date, search by email, open a page to read the full message, or build a Notion view on top of it, exactly the way your team already works with everything else in your workspace.&lt;/p&gt;

&lt;p&gt;Formgrid is where you manage the human side of each enquiry. Pipeline stages, follow-up reminders, conversion tracking, AI categorization, and replying from your own Gmail address without leaving the dashboard. None of that exists natively inside Notion.&lt;/p&gt;

&lt;p&gt;Connected, your data lives in Notion, and your workflow runs in Formgrid. Most people come looking for a way to get form data into Notion and only discover the follow-up side once they see it working, which tends to be the part that actually justifies paying for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Managing Your Connection
&lt;/h2&gt;

&lt;p&gt;Once connected, the Integrations tab shows your connection status.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause the integration&lt;/strong&gt; using the Active toggle. When paused, new submissions are still saved to Formgrid but not sent to Notion. Toggle it back on to resume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disconnect&lt;/strong&gt; removes the connection entirely. Your existing Notion pages stay exactly as they are, but new submissions no longer sync.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Could not access this database"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The database was not shared with the Formgrid integration during the Notion authorization step. Reconnect and make sure to select the database you want to use when Notion asks which pages and databases to share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Properties look wrong or missing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Properties are created automatically from your form's field names on the first submission. If you renamed a field after connecting, new properties are created rather than the old one being renamed, so it is worth connecting the integration after your form fields are finalized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submissions not appearing in Notion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check that the integration is Active in your Integrations tab. If it shows Paused, click the toggle to resume.&lt;/p&gt;




&lt;h2&gt;
  
  
  How This Compares to Zapier
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Zapier Route&lt;/th&gt;
&lt;th&gt;Formgrid Native&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;20 to 40 minutes&lt;/td&gt;
&lt;td&gt;Under 5 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra cost&lt;/td&gt;
&lt;td&gt;$20 or more per month for Zapier&lt;/td&gt;
&lt;td&gt;Included with Formgrid Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works with existing HTML forms&lt;/td&gt;
&lt;td&gt;Only with extra configuration&lt;/td&gt;
&lt;td&gt;Yes, by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real time&lt;/td&gt;
&lt;td&gt;Depends on Zap frequency&lt;/td&gt;
&lt;td&gt;Instant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical knowledge&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What You Can Do With the Data Once It Is in Notion
&lt;/h2&gt;

&lt;p&gt;Once submissions are landing as pages in your database, you get everything Notion already gives you for free: view all submissions as a table, filter by date or by any property, search by email, sort by most recent, or open an individual submission to read the full message. If your team already manages projects or clients in Notion, new leads now show up right alongside that work instead of living in a separate inbox.&lt;/p&gt;




&lt;h2&gt;
  
  
  Plan Limits
&lt;/h2&gt;

&lt;p&gt;Formgrid's free plan includes 10 Notion pages, one-time, not a monthly reset, along with 10 AI Smart Inbox analyses, also one-time. That is enough to fully test both the Notion integration and the AI categorization, confirm they work with your form, and see exactly what a submission looks like before deciding to upgrade.&lt;/p&gt;

&lt;p&gt;The Premium plan, at $12 per month, raises those limits considerably and adds everything else covered in this post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500 Notion pages per month
1,000 AI Smart Inbox analyses per month
Google Sheets sync, 500 rows per month
Email notifications to up to 3 recipients
Full lead pipeline with custom stages,
  notes, follow-up reminders, and
  conversion rate tracking
Reply to Lead: reply from your own Gmail
  address directly from the dashboard
CSV export and bulk operations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Business plan, at $29 per month, includes unlimited Notion pages and unlimited Google Sheets sync, alongside everything in Premium.&lt;/p&gt;

&lt;p&gt;None of the above requires Zapier, at any plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;If your team runs on Notion, there is no good reason for your form submissions to live somewhere else. Zapier adds a monthly cost and a Zap you have to maintain. Manual export is real work you will eventually forget to do. Writing your own integration means hosting and maintaining backend code for something that should not need it.&lt;/p&gt;

&lt;p&gt;Formgrid's Notion integration replaces all of that with a one-time connection: authorize once, pick your database, and every submission from that point forward becomes a page automatically, in real time, with no further setup.&lt;/p&gt;

&lt;p&gt;Most people set this up because they want their data in Notion. What tends to keep them on the Premium plan is everything running alongside it: the AI-categorized subject lines, the lead pipeline, and replying to a lead without leaving the dashboard. That is the same pattern behind Formgrid's Google Sheets integration: people come for the spreadsheet sync and stay once they see the rest of the workflow working.&lt;/p&gt;

&lt;p&gt;Try it on the free plan first. Ten submissions are enough to see it working end-to-end before you decide whether to upgrade.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://formgrid.dev/app/signup" rel="noopener noreferrer"&gt;Connect your form to Notion at formgrid.dev&lt;/a&gt;. No credit card required to start.&lt;/p&gt;

</description>
      <category>notion</category>
      <category>form</category>
      <category>integration</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Added AI Lead Classification to My Form Tool Without Slowing Down Submissions</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Fri, 28 Aug 2026 14:42:19 +0000</pubDate>
      <link>https://dev.to/allenarduino/i-added-ai-lead-classification-to-my-form-tool-without-slowing-down-submissions-3fo7</link>
      <guid>https://dev.to/allenarduino/i-added-ai-lead-classification-to-my-form-tool-without-slowing-down-submissions-3fo7</guid>
      <description>&lt;p&gt;One of the things I've learned from running a SaaS is that a feature can be technically easy and still be a difficult production problem.&lt;/p&gt;

&lt;p&gt;Form submissions are a good example. My product already knew the moment a submission arrived. The harder problem was deciding what deserved a business owner's attention. A genuine customer asking for a quote, a spam bot, an SEO sales pitch, and a support request could all arrive in the inbox looking identical, same subject line, same formatting, with no way to tell which one actually mattered.&lt;/p&gt;

&lt;p&gt;I wanted the system to figure out what each submission actually was before the business owner opened the email.&lt;/p&gt;

&lt;p&gt;So I built an AI layer that classifies every submission, assigns a priority, generates a summary, and puts that information directly into the notification email and the dashboard.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't calling an LLM. It was making the feature cheap enough to run on every single submission, fast enough that it couldn't affect form delivery, and resilient enough that an AI failure could never cause a lost lead.&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a simple LLM call wasn't enough
&lt;/h2&gt;

&lt;p&gt;The naive version of this feature is easy to picture: submission comes in, call an LLM, wait for the response, attach the result to the notification. That works fine in a demo. It falls apart under a few real constraints:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; This runs on every submission, on every form, for every account on the platform. If the LLM call sits in the request path, a slow model response means a slow form submission, and a visitor filling out a contact form has no idea or reason to care that AI is running behind the scenes. Their form just needs to submit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reliability.&lt;/strong&gt; LLM APIs fail, time out, or rate-limit. If classification is a required step before a submission is accepted, then an AI outage becomes a lost lead, which is the one outcome this feature was never allowed to cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost at scale.&lt;/strong&gt; This isn't a one-off classification task; it's every submission, indefinitely. A model chosen because it produced impressive answers in testing can turn into a real cost problem once it's running continuously in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured, consistent output.&lt;/strong&gt; I needed a category from a fixed list, a priority level, and a summary, every time, in a format I could parse reliably. Not a flexible chat response I'd have to coerce into structure after the fact.&lt;/p&gt;

&lt;p&gt;Each of those ruled out the "just call an LLM inline" version of the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The fix was to take the AI entirely off the critical path. The visitor's submission gets accepted and confirmed immediately. The classification happens afterward, as a background step that the request itself doesn't wait on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌───────────────┐
                    │  Form visitor │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │ Formgrid API  │
                    └───────┬───────┘
                            │
                   persist submission
                            │
                            ▼
                    ┌───────────────┐
                    │ HTTP response │  ← visitor sees success instantly
                    └───────────────┘

                            │
                     background job
                            ▼
                    ┌───────────────┐
                    │ Gemini Flash  │
                    └───────┬───────┘
                            │
                  classification + priority
                        + summary
                            │
                            ▼
                    ┌───────────────┐
                    │  Update lead  │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │ Notification  │
                    │     email     │
                    └───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visitor never waits on the AI step; they never even know it exists. If the classification job fails or times out, the submission has already been saved, and the business owner still gets notified, just without a category attached. The AI layer can degrade without ever taking the core product down with it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The lead detail view for the first example below, with category, priority, and summary generated automatically.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fww6wl671et8dojgryfyb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fww6wl671et8dojgryfyb.png" alt="Screenshot: the Formgrid dashboard lead detail view for the agency submission from the table above, showing the " width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the model
&lt;/h2&gt;

&lt;p&gt;I didn't benchmark models based on which one produced the most impressive-sounding answer. I benchmarked against the actual task: a six-way classification, a priority level, and a two-to-three sentence summary, run on every submission across every account on the platform.&lt;/p&gt;

&lt;p&gt;That reframing changed the decision. A larger, more capable model would have added cost and latency without adding accuracy that mattered for a fairly constrained classification problem. I went with Google Gemini Flash; it gave me the speed and the economics the task actually needed, while being more than capable of handling six categories and a summary consistently.&lt;/p&gt;

&lt;p&gt;The six categories I landed on, after looking at what actually shows up in a real form inbox:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Potential Customer:&lt;/strong&gt; Genuine buying or engagement intent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Likely Spam:&lt;/strong&gt; Automated or irrelevant submissions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sales Pitch:&lt;/strong&gt; Someone trying to sell to the business, not buy from it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support Request:&lt;/strong&gt; An existing customer with a question or issue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job Application:&lt;/strong&gt; Someone applying for a role&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General Enquiry:&lt;/strong&gt; A real message that doesn't fit anywhere else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each submission also gets a priority, High, Medium, or Low, based on urgency and specificity rather than category alone. A potential customer asking a vague question is Medium. A potential customer asking about pricing for 50 seats by Friday is High.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling failure without losing a lead
&lt;/h2&gt;

&lt;p&gt;The rule I held myself to: an AI failure can never cause a lost lead. If Gemini times out, errors, or the account hits its analysis limit for the billing period, the submission still saves, the business owner still gets notified, and it arrives without a category badge instead of breaking anything downstream. The AI layer only ever adds information. It's never allowed to be a dependency the core product relies on to function.&lt;/p&gt;

&lt;p&gt;That constraint is also why this couldn't be an inline, request-blocking call in the first place. Once the submission is treated as complete the moment it's persisted, an AI failure downstream literally cannot touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the classification actually looks like
&lt;/h2&gt;

&lt;p&gt;Abstract categories are easier to trust with real examples. Here's what four different submissions to the same contact form produce:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Submission&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;AI summary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Hi, we're a 40-person agency looking for a form tool with an API. Can you send pricing for the Business plan? We'd want to migrate by the end of the month."&lt;/td&gt;
&lt;td&gt;Potential Customer&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Agency evaluating Business plan for a 40-person team, wants to migrate by end of month, time-sensitive purchase intent.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"I'm an existing customer, my Google Sheets sync stopped working yesterday, can someone help?"&lt;/td&gt;
&lt;td&gt;Support Request&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Existing customer reporting broken Google Sheets sync since yesterday, needs troubleshooting.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Do you offer a student discount?"&lt;/td&gt;
&lt;td&gt;General Enquiry&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Prospective user asking about pricing discounts, no urgency indicated.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"hey i can get you to page 1 of google in 30 days guaranteed, reply for more info"&lt;/td&gt;
&lt;td&gt;Sales Pitch&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Unsolicited SEO service pitch, not a product enquiry.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"asdkjf83 !!! CLICK HERE now4free-followers.xyz buy cheap instagram followers 100% guaranteed !!!"&lt;/td&gt;
&lt;td&gt;Likely Spam&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Automated or bot-generated submission with no coherent message, promotional link pattern typical of spam.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same form, same inbox, five completely different submissions, and now five completely different subject lines instead of one identical "New submission" notification for all of them. That's the whole point of the feature: the business owner never has to open the spam or the SEO pitch to know they don't matter, and never risks missing the agency lead because it's buried between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't measured yet
&lt;/h2&gt;

&lt;p&gt;I'd like to be honest about where the data stands rather than make this sound more finished than it is: I don't yet have hard numbers on what percentage of submissions get classified as spam versus genuine leads across accounts, or the exact inference cost per 1,000 submissions at current volume. I'm instrumenting both now, cost per classification and category distribution across real accounts, and I plan to publish those numbers once I have a few weeks of real data behind them rather than guess at them here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it shows up
&lt;/h2&gt;

&lt;p&gt;Once a submission is classified, the result appears in three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The email subject line&lt;/strong&gt;, as a prefix before the form name, &lt;code&gt;[Hot Lead]&lt;/code&gt;, &lt;code&gt;[Likely Spam]&lt;/code&gt;, &lt;code&gt;[Sales Pitch]&lt;/code&gt;, &lt;code&gt;[Support Request]&lt;/code&gt;, so it's readable without opening the email at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The email body&lt;/strong&gt;, with the priority, category, and full summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dashboard&lt;/strong&gt;, as a colored badge on every lead row, with a filter to pull up just the high-priority leads or exclude spam entirely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The clearest way to see it in one place is the leads list itself, all five example submissions from the table above, each carrying its own category badge:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4iugmnip66t2era2hf6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4iugmnip66t2era2hf6.png" alt="Screenshot: a leads list view with rows corresponding to the example submissions above, each with its colored AI category badge visible (" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Caption: All example submissions in the leads list, filterable by category.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The form tool behind this
&lt;/h2&gt;

&lt;p&gt;The product this shipped on is &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;Formgrid&lt;/a&gt;, an open-source form backend and lead management platform I've been building and running on my own since September 2025, alongside a full-time contract role. Every form submission on Formgrid becomes a tracked lead automatically, and this AI layer, which I've been calling AI Smart Inbox, is the newest piece of that pipeline.&lt;/p&gt;

&lt;p&gt;If you want to see it running on a real submission, every account gets 10 free AI analyses to try it with no setup required.&lt;/p&gt;

&lt;p&gt;The broader lesson, for me at least: the hard part of shipping AI in a real product usually isn't the model call. It's everywhere around it, keeping it off the critical path, deciding what happens when it fails, and picking a model sized to the actual task instead of the most impressive one available.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>leadcapture</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Don't Build Your Own Form Backend. Here's What It Actually Costs You.</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Thu, 27 Aug 2026 11:00:51 +0000</pubDate>
      <link>https://dev.to/allenarduino/dont-build-your-own-form-backend-heres-what-it-actually-costs-you-1kc1</link>
      <guid>https://dev.to/allenarduino/dont-build-your-own-form-backend-heres-what-it-actually-costs-you-1kc1</guid>
      <description>&lt;p&gt;Every developer has this thought at some point. "It's just a form. I'll write a quick endpoint, send an email with Resend or SendGrid, and be done in an afternoon."&lt;/p&gt;

&lt;p&gt;Then you actually build it.&lt;/p&gt;

&lt;p&gt;Two hours in, you have a working POST endpoint. Three days later, you are writing spam filtering rules by hand. Two weeks in, a client asks, "Can you show me all the submissions from last month?" and you realize you never built a dashboard, so you are querying your production database directly to answer a simple question. A month in, you are debugging why half your notification emails are landing in spam because you never set up SPF and DKIM records properly.&lt;/p&gt;

&lt;p&gt;None of that was in the "quick afternoon" estimate.&lt;/p&gt;

&lt;p&gt;This post explains why building your own form backend is almost always the wrong call, how much unexpected time it can cost you, and what you should use instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Feels Like It Should Be Easy
&lt;/h2&gt;

&lt;p&gt;On the surface, a form backend looks trivial. Receive a POST request, read the fields, send an email. That part genuinely does take an afternoon.&lt;/p&gt;

&lt;p&gt;The problem is that a form backend that is actually usable in production is not that. It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. An endpoint that accepts submissions
2. Spam and bot filtering
3. Email delivery that does not land in spam
4. A dashboard to view past submissions
5. Storage that does not disappear
6. Rate limiting so one bad actor can't flood you
7. Input validation and sanitization
8. File upload handling, if you need it
9. A way to track what happens after a submission arrives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You do not see most of this list on day one. You see it the first time a bot floods your endpoint, the first time a client asks to see old submissions, or the first time your notification emails start landing in spam because you are sending transactional email from a domain with no reputation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Costs Nobody Budgets For
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Spam filtering.&lt;/strong&gt; The moment your form is public, bots find it. Not eventually, immediately. You will need a honeypot field at minimum, and realistically CAPTCHA or a service like hCaptcha or Cloudflare Turnstile on top of that. Writing spam heuristics that catch crypto spam, disposable emails, and HTML injection without also blocking real users takes real iteration, not a weekend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email deliverability.&lt;/strong&gt; Sending email reliably is its own discipline. SPF, DKIM, DMARC, a sending domain with reputation, retry logic for failed sends, and a fallback for when your email provider has an outage. Get any of this wrong, and your "notification" system silently stops notifying you, which is the worst possible failure mode for a form backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dashboard.&lt;/strong&gt; The first time someone asks "can I see submissions from before I set up email forwarding" or "can you export the last 200 leads to CSV," you realize you built an endpoint, not a system. Now you are building an admin panel: auth, pagination, search, filtering, export. That is not a form backend feature anymore; that is a small internal product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage and retention.&lt;/strong&gt; Where do submissions live? For how long? What happens when the database grows? Do you need backups? None of this is form-backend-specific; it is generic infrastructure you now own because you decided to roll your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting and abuse prevention.&lt;/strong&gt; Without it, one bad actor can submit your form thousands of times a minute, which either racks up your email sending bill or takes down your database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GDPR and data handling.&lt;/strong&gt; If you collect any personal data from a form, and you almost always do, you now have a small compliance surface: right to deletion, data retention policy, and knowing exactly where the data lives. This is easy to ignore until someone asks you to delete their data and you have no clean way to find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ongoing maintenance.&lt;/strong&gt; Every one of the systems above needs to keep working as your dependencies update, as spam patterns evolve, and as your email provider changes their API. A form backend is not something you build once. It is something you maintain forever, or it slowly rots.&lt;/p&gt;

&lt;p&gt;None of this is a knock on your ability to build it. All of it is buildable. The question is whether it is worth building, given that it is not the product you are actually trying to ship.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Math
&lt;/h2&gt;

&lt;p&gt;Say you are honest about the real scope: endpoint, spam filtering, email delivery, a basic dashboard, and rate limiting. That is not an afternoon. That is realistically two to four days of focused work if you already know what you are doing, longer if you do not.&lt;/p&gt;

&lt;p&gt;At any reasonable hourly rate, two to four days of engineering time costs more than a year of a managed form backend, and that is before counting the maintenance time you will keep spending every time something breaks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DIY form backend:
  2-4 days initial build
  + ongoing maintenance
  + your time debugging deliverability issues
  + your time when a client asks for a feature
    the endpoint does not have

Managed form backend:
  10 minutes to set up
  $0-12/month
  Someone else's job to keep spam filtering,
  deliverability, and uptime working
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same tradeoff as writing your own authentication system instead of using an auth provider, or rolling your own payment processing instead of using Stripe. It is not that you cannot build it. It is that building it is rarely the highest-value use of your time, because it is infrastructure, not your product.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Building Your Own Actually Makes Sense
&lt;/h2&gt;

&lt;p&gt;To be fair, there are real cases where rolling your own is the right call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You have genuinely unusual requirements
no service can meet

You are building this as the product itself,
not as infrastructure for something else

You have strict data residency or compliance
requirements that require full control

You already have this infrastructure built
for other reasons and reusing it is nearly free

You are doing it deliberately as a learning
exercise, not for a production client site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If none of those apply to you, and for most people building a contact form, a quote request form, or a lead capture form on a client site or their own product, none of them do, a managed service is the better trade every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "Just Use a Service" Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;Here is the entire difference between the DIY approach and using a form backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DIY, roughly:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// You write and maintain all of this yourself&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// validate input&lt;/span&gt;
  &lt;span class="c1"&gt;// check honeypot / run captcha verification&lt;/span&gt;
  &lt;span class="c1"&gt;// rate limit by IP&lt;/span&gt;
  &lt;span class="c1"&gt;// sanitize fields&lt;/span&gt;
  &lt;span class="c1"&gt;// save to a database you provisioned&lt;/span&gt;
  &lt;span class="c1"&gt;// send email via a provider you configured&lt;/span&gt;
  &lt;span class="c1"&gt;// handle delivery failures&lt;/span&gt;
  &lt;span class="c1"&gt;// return a response&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Using a form backend:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://formgrid.dev/api/f/your-form-id"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Your Name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Your Email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Your Message"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send Message&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire integration. No server code. No database to provision. No email service to configure. Spam filtering, delivery, storage, and a dashboard all come with the endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting This Up Takes About Ten Minutes
&lt;/h2&gt;

&lt;p&gt;If you want to see exactly how little is involved, here is the full setup using Formgrid, an open-source form backend, as the example.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create an Account
&lt;/h3&gt;

&lt;p&gt;Head to &lt;a href="https://formgrid.dev" rel="noopener noreferrer"&gt;formgrid.dev&lt;/a&gt; and sign up with Google or email. No credit card required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx2pat1cb833frkflg7bt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx2pat1cb833frkflg7bt.png" alt=" " width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create a Form and Copy the Endpoint
&lt;/h3&gt;

&lt;p&gt;Click New Form, give it a name, and click Create Form.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fozar28d2u9v4whi05ogg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fozar28d2u9v4whi05ogg.png" alt=" " width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the Overview tab, copy your endpoint URL.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcbr1ogkbp6odvk7v2zm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcbr1ogkbp6odvk7v2zm.png" alt=" " width="799" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Point Your Form at It
&lt;/h3&gt;

&lt;p&gt;Drop the endpoint into your form's &lt;code&gt;action&lt;/code&gt; attribute. If you already have a form on your site, this is the only line that changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://formgrid.dev/api/f/your-form-id"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- honeypot, invisible to real users --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"_honey"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:none"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spam filtering, rate limiting, and bot protection are already active. You did not configure any of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Notifications Are Already On
&lt;/h3&gt;

&lt;p&gt;Nothing to set up here. The email address you signed up with is the default notification address the moment your form goes live. If you want submissions sent somewhere else, like a client's inbox or a shared team address, that is a single field in Settings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8kp2sswbjh2dvwvi41ed.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8kp2sswbjh2dvwvi41ed.png" alt=" " width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Every Submission Is Already a Tracked Lead
&lt;/h3&gt;

&lt;p&gt;This is the part a DIY endpoint never gets to, because building it is a whole separate project. Every submission lands in a dashboard automatically, with a status you can move through New, Contacted, and Converted, not just a one-off email you have to remember to act on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcy6o7wogoc68ewiqozt7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcy6o7wogoc68ewiqozt7.png" alt=" " width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can see the submitter's details, add a private note, and set a follow-up reminder, all without writing a line of backend code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjta68h51law54ktrr5w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjta68h51law54ktrr5w.png" alt=" " width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the entire setup. No server to maintain, no email provider to configure, no dashboard to build, and no spam filtering logic to write and keep tuning.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Get That You Would Not Have Built Yourself
&lt;/h2&gt;

&lt;p&gt;This is really the heart of the argument. It is not just that a managed form backend saves you the two to four days of initial build time. It is that most people never get around to building the parts that make a form backend actually useful, because those parts are not urgent until they suddenly are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A lead pipeline.&lt;/strong&gt; Not just an inbox. Every submission gets a status, New, Contacted, Converted, so you know exactly what happened after someone filled out your form. This is the single most common gap in a DIY form backend, because building it means building a small CRM, not a form handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic submission triage.&lt;/strong&gt; Modern form backends can categorize what came in for you, flagging a genuine enquiry versus a sales pitch versus spam, and surfacing the important ones first. Building this yourself means training or wiring up a classification model on top of everything else on this list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permanent, searchable storage.&lt;/strong&gt; Not a 30-day window, not a database table you have to remember to back up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integrations you did not have to build.&lt;/strong&gt; Google Sheets sync, Notion sync, webhooks, CSV export. Each of these is a real integration with its own API quirks and edge cases if you build it yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spam filtering that improves without your involvement.&lt;/strong&gt; You are not the one updating the spam patterns when a new kind of crypto spam campaign shows up.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It is just the difference between a weekend project and a product that someone maintains full-time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Building your own form backend is a genuinely fun weekend project, and if that is what it is for you, build it. There is real value in understanding how the pieces fit together.&lt;/p&gt;

&lt;p&gt;But if the form is infrastructure for something else, a client site, your own product's contact page, a lead capture flow for a business, it is almost never worth the time. The part that looks like the whole job, receive a POST request and send an email, is maybe 10% of what a form backend actually needs to do well. The other 90% is spam filtering, deliverability, a dashboard, storage, and a way to track what happens after someone submits, and that 90% is exactly the part that never makes it into the "quick afternoon" estimate.&lt;/p&gt;

&lt;p&gt;Stop building your own form backend. Point your form at one instead, and spend the time you saved on the thing you were actually trying to build.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://formgrid.dev/app/signup" rel="noopener noreferrer"&gt;Try Formgrid free at formgrid.dev&lt;/a&gt;. No credit card required.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I built Formgrid. I wrote this as honestly as I could. If anything looks inaccurate, let me know in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>formbackend</category>
      <category>formgrid</category>
      <category>spamprotection</category>
    </item>
    <item>
      <title>Build a Restaurant Website in HTML with a Menu That Updates from Google Sheets</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Wed, 26 Aug 2026 16:36:00 +0000</pubDate>
      <link>https://dev.to/allenarduino/build-a-restaurant-website-in-html-with-a-menu-that-updates-from-google-sheets-5hic</link>
      <guid>https://dev.to/allenarduino/build-a-restaurant-website-in-html-with-a-menu-that-updates-from-google-sheets-5hic</guid>
      <description>&lt;p&gt;Most restaurant websites have the same problem.&lt;/p&gt;

&lt;p&gt;The menu is hardcoded in HTML. Every time a price changes or a dish gets added, someone has to edit the website. If the owner does not know how to code,  they call their developer. The developer charges for the update. The menu is always slightly out of date.&lt;/p&gt;

&lt;p&gt;This tutorial shows you a different approach. You &lt;br&gt;
build a complete restaurant website with plain HTML. &lt;br&gt;
The menu section pulls live data from a Google Sheet. &lt;br&gt;
When the owner changes a price or adds a new dish, the &lt;br&gt;
website updates automatically. No developer needed. &lt;br&gt;
No rebuild. No redeploy.&lt;/p&gt;

&lt;p&gt;Here is what you will build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A complete restaurant website in plain HTML and CSS&lt;/li&gt;
&lt;li&gt;A live menu section powered by a Google Sheet&lt;/li&gt;
&lt;li&gt;Search and filter by category&lt;/li&gt;
&lt;li&gt;Cart and online ordering built in&lt;/li&gt;
&lt;li&gt;Automatic order email to the restaurant owner&lt;/li&gt;
&lt;li&gt;Hosted on any static platform for free&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The menu at the bottom of this page is a live demo. &lt;br&gt;
It is pulling real data from a Google Sheet right now.&lt;/p&gt;


&lt;h2&gt;
  
  
  What You Will Need
&lt;/h2&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A text editor (VS Code or any editor)&lt;/li&gt;
&lt;li&gt;A Google account&lt;/li&gt;
&lt;li&gt;A free Sheetrocket account&lt;/li&gt;
&lt;li&gt;Basic HTML knowledge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No server. No backend. No database.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here is how the whole thing works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Restaurant owner edits Google Sheet
          |
          v
Sheetrocket reads the sheet via API
          |
          v
Widget script fetches the latest data
          |
          v
Menu renders on the HTML page automatically
          |
          v
Customer adds to cart and places order
          |
          v
Restaurant owner receives order email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restaurant owner never touches the website. &lt;br&gt;
They manage everything in a spreadsheet they already know how to use.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Set Up Your Google Sheet
&lt;/h2&gt;

&lt;p&gt;Start with your menu data. Create a new Google &lt;br&gt;
Sheet with the following columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name | Price | Category | Description | Image URL | Available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a sample menu to get you started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name              | Price  | Category | Description                    | Image URL | Available
Margherita Pizza  | $12.99 | Pizza    | Classic tomato and mozzarella  | https://... | Yes
Pepperoni Pizza   | $14.99 | Pizza    | Spicy pepperoni and cheese     | https://... | Yes
Caesar Salad      | $8.99  | Salads   | Romaine, croutons, parmesan    | https://... | Yes
Grilled Chicken   | $16.99 | Mains    | Herb marinated grilled chicken | https://... | Yes
Chocolate Cake    | $6.99  | Desserts | Rich dark chocolate layers     | https://... | Yes
Lemonade          | $3.99  | Drinks   | Fresh squeezed with mint       | https://... | Yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can use any image URLs from Unsplash, your own &lt;br&gt;
Google Drive, or any image hosting service. For &lt;br&gt;
Unsplash images, add &lt;code&gt;?w=400&amp;amp;h=300&amp;amp;fit=crop&lt;/code&gt; to &lt;br&gt;
the end of the URL to get a consistent size.&lt;/p&gt;

&lt;p&gt;Keep the column names exactly as shown. Sheetrocket &lt;br&gt;
will use them for automatic mapping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F37mkciykl4nm6hb61g3q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F37mkciykl4nm6hb61g3q.png" alt="Screenshot of the Google Sheet with menu data filled in across all columns" width="799" height="349"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 2: Create Your SheetRocket Account
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;sheetrocket.com&lt;/a&gt; and &lt;br&gt;
sign up for free. No credit card required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdjlx4qlpf3ww9w1r6m5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdjlx4qlpf3ww9w1r6m5.png" alt="Sheetrocket Landing Page Screenshot" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;





&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Favq8pgq3k14vm63hbk1a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Favq8pgq3k14vm63hbk1a.png" alt="Screenshot of the Sheetrocket signup page" width="799" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you are logged in, you will see your dashboard. &lt;br&gt;
This is where you create and manage your widgets, &lt;br&gt;
sites, and APIs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbo68cvew2eju60eslvf4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbo68cvew2eju60eslvf4.png" alt="Screenshot of the Sheetrocket dashboard with the sidebar showing Widgets, Sites, and APIs" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Create a Menu Widget
&lt;/h2&gt;

&lt;p&gt;Click &lt;strong&gt;Widgets&lt;/strong&gt; in the left sidebar. Then click &lt;br&gt;
&lt;strong&gt;Create widget&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj5uv3r6k9x5hnbymalxz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj5uv3r6k9x5hnbymalxz.png" alt="Screenshot of the Widgets page with the Create widget button highlighted" width="799" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will see a gallery of widget templates. Click &lt;br&gt;
on &lt;strong&gt;Menu Widget&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm87cekqikuzf09b2adtf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm87cekqikuzf09b2adtf.png" alt="Screenshot of the widget template gallery with Menu Widget highlighted" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Give your widget a name. Something like &lt;br&gt;
"Allen's Restaurant Menu" works. Click &lt;strong&gt;Create&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr09gjwhj7tfmjdrg6y8r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr09gjwhj7tfmjdrg6y8r.png" alt="Screenshot of the widget naming step" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will be taken to the widget builder. On the right side, you will see a live preview of your widget with sample data. On the left side, you have three tabs: Data, Design, and Filters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17nf8kil5ohtmug1rhxl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17nf8kil5ohtmug1rhxl.png" alt="Screenshot of the widget builder with sample data showing in the preview panel" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 4: Connect Your Google Sheet
&lt;/h2&gt;

&lt;p&gt;Click the &lt;strong&gt;Data&lt;/strong&gt; tab in the left panel.&lt;/p&gt;

&lt;p&gt;You will see two things: a data source section &lt;br&gt;
and the column mapping fields below it. The column mapping fields are always visible so you can see exactly what columns your sheet needs before connecting.&lt;/p&gt;

&lt;p&gt;Sheetrocket uses a service account to read your Google Sheet securely. You need to share your sheet with the SheetRocket service account email.&lt;/p&gt;

&lt;p&gt;Find the service account email in the Data tab. &lt;br&gt;
It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sheetrocket@sheetrocket-app.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdd8en6jpgskr0dsoa2hg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdd8en6jpgskr0dsoa2hg.png" alt="Screenshot of the Data tab showing the service account email with a copy button" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Copy that email address. Now go back to your &lt;br&gt;
Google Sheet.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Share&lt;/strong&gt; in the top right corner of your &lt;br&gt;
Google Sheet. Paste the service account email &lt;br&gt;
into the share field. Set the permission to &lt;br&gt;
&lt;strong&gt;Viewer&lt;/strong&gt;. Click &lt;strong&gt;Share&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn4bnkyfrm99jbkoxrij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn4bnkyfrm99jbkoxrij.png" alt="Screenshot of the Google Sheets share dialog" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwhae2yi4g585shrqbhr5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwhae2yi4g585shrqbhr5.png" alt="Screenshot of the Google Sheets share dialog with the service account email pasted in" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now copy the URL of your Google Sheet from your browser's address bar. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://docs.google.com/spreadsheets/d/
1abc123def456/edit#gid=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go back to SheetRocket and paste this URL into &lt;br&gt;
the &lt;strong&gt;Google Sheet URL&lt;/strong&gt; field in the Data tab. &lt;br&gt;
Click &lt;strong&gt;Connect&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqna5caikp5lwd4nu5os.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqna5caikp5lwd4nu5os.png" alt="Screenshot of the Data tab with the Google Sheet URL pasted in and the Connect button" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sheetrocket will read your sheet and automatically &lt;br&gt;
detect your columns. You will see a success message, and the auto-mapping will pre-fill the column &lt;br&gt;
mapping fields.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkgv3fkrl6a25fsmvkje.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkgv3fkrl6a25fsmvkje.png" alt="Screenshot showing successful sheet connection with columns auto-mapped and a green success indicator" width="800" height="1119"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Review the column mapping. SheetRocket should &lt;br&gt;
have matched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item name    →   Name
Price        →   Price
Image        →   Image URL
Category     →   Category
Description  →   Description
Available    →   Available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any mapping is off, use the dropdown to &lt;br&gt;
correct it. Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your preview on the right side of the builder &lt;br&gt;
will now show your real menu items.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fptab8wzfh2lvm0c2wx16.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fptab8wzfh2lvm0c2wx16.png" alt="Screenshot of the widget builder with real menu items showing in the preview, including food photos" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 5: Customize the Design
&lt;/h2&gt;

&lt;p&gt;Click the &lt;strong&gt;Design&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;Here you can match the widget to your restaurant &lt;br&gt;
branding:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theme:&lt;/strong&gt; Choose Light or Dark. Dark works well &lt;br&gt;
for upscale restaurants. Light works for casual &lt;br&gt;
cafes and family restaurants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accent color:&lt;/strong&gt; Set this to your restaurant's &lt;br&gt;
primary brand color. This affects badge colors &lt;br&gt;
and the cart button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Card columns:&lt;/strong&gt; Choose 2, 3, or 4. Three columns work well for most menu layouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image height:&lt;/strong&gt; 200px to 240px works for most &lt;br&gt;
food photography.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Card radius:&lt;/strong&gt; 12px gives a modern rounded look. &lt;br&gt;
0px gives a sharper, more minimal look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvrnzb55jvfuvj4j4lc1j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvrnzb55jvfuvj4j4lc1j.png" alt="First Screenshot of the Design tab with the color picker and column options visible" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4cb1imn0f6rnn6mcraa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4cb1imn0f6rnn6mcraa.png" alt="Second Screenshot of the Design tab with the color picker and column options visible" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmykkhqu3fa6ya20eyasq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmykkhqu3fa6ya20eyasq.png" alt="Third Screenshot of the Design tab with the color picker and design options visible" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fibjp27m5hjdjfwo1mb4h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fibjp27m5hjdjfwo1mb4h.png" alt="Fourth Screenshot of the Design tab with the color picker and design options visible" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The preview updates in real time as you make &lt;br&gt;
changes. Take your time here to get it right before moving on.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 6: Set Up Category Filtering
&lt;/h2&gt;

&lt;p&gt;Click the &lt;strong&gt;Filters&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;Enable &lt;strong&gt;Show search bar&lt;/strong&gt;. This lets customers search for specific dishes by name.&lt;/p&gt;

&lt;p&gt;Enable &lt;strong&gt;Show filter dropdown&lt;/strong&gt;. Set the filter &lt;br&gt;
column to &lt;strong&gt;Category&lt;/strong&gt;. This adds a dropdown &lt;br&gt;
that lets customers filter by Pizza, Salads, &lt;br&gt;
Mains, Desserts, and Drinks.&lt;/p&gt;

&lt;p&gt;Enable &lt;strong&gt;Pagination&lt;/strong&gt; if you have more than 12 &lt;br&gt;
menu items. Set items per page to 12.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fezaj9x4cxvdc6zq37nec.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fezaj9x4cxvdc6zq37nec.png" alt="Screenshot of the Filters tab with search enabled, category filter enabled, and pagination on" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your preview will now show the search bar and &lt;br&gt;
filter dropdown at the top of the menu.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 7: Enable Cart and Ordering
&lt;/h2&gt;

&lt;p&gt;This is the part that turns a menu display into an actual ordering system.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;Design&lt;/strong&gt; tab, scroll down to the &lt;br&gt;
&lt;strong&gt;Button&lt;/strong&gt; section.&lt;/p&gt;

&lt;p&gt;Toggle &lt;strong&gt;Add to cart mode&lt;/strong&gt; on. This changes &lt;br&gt;
the button on each menu card from a link to &lt;br&gt;
a circular plus button. Customers tap the &lt;br&gt;
plus to add items to their cart.&lt;/p&gt;

&lt;p&gt;Set the &lt;strong&gt;Button color&lt;/strong&gt; to match your &lt;br&gt;
accent color.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngtoko5hevl6o47yyzfv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngtoko5hevl6o47yyzfv.png" alt="First Screenshot of the button settings with Add to cart mode toggled on" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxqkipklsszfkmild51d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxqkipklsszfkmild51d.png" alt="Second Screenshot of the button settings with Add to cart mode toggled on" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvsm34f3zuhnyvp33ims6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvsm34f3zuhnyvp33ims6.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now scroll down further to the &lt;strong&gt;Cart settings&lt;/strong&gt; &lt;br&gt;
section.&lt;/p&gt;

&lt;p&gt;Set your &lt;strong&gt;Order notification email&lt;/strong&gt;. This is where SheetRocket will send an email every time a customer places an order. Use the &lt;br&gt;
restaurant owner's email address.&lt;/p&gt;

&lt;p&gt;You can also configure what fields to collect &lt;br&gt;
at checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name         (required, always on)
Email        (required, always on)
Phone        toggle on or off
Address      toggle on or off
Notes        toggle on or off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a restaurant taking delivery orders, turn on Phone and Address. For takeaway orders, just Phone is usually enough.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxvn3bd9o3o4zegxy0290.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxvn3bd9o3o4zegxy0290.png" alt="First Screenshot of the cart settings showing order email field and checkout field toggles" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3kpr83yeik8v8659gxy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3kpr83yeik8v8659gxy.png" alt="Second Screenshot of the cart settings showing order email field and checkout field toggles" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Toggle &lt;strong&gt;Send confirmation email to customer&lt;/strong&gt; on. When a customer places an order, they will receive an automatic confirmation email with their order details.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjk0x6j94yrt4a47iqqze.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjk0x6j94yrt4a47iqqze.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Copy Your Embed Code
&lt;/h2&gt;

&lt;p&gt;Click the &lt;strong&gt;Embed&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;You will see your embed code. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;data-sheetrocket=&lt;/span&gt;&lt;span class="s"&gt;"your-widget-id"&lt;/span&gt;
  &lt;span class="na"&gt;data-template=&lt;/span&gt;&lt;span class="s"&gt;"menu-widget"&lt;/span&gt;
  &lt;span class="na"&gt;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"light"&lt;/span&gt;
  &lt;span class="na"&gt;data-columns=&lt;/span&gt;&lt;span class="s"&gt;"3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script
  &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://sheetrocket.com/widget.js"&lt;/span&gt;
  &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click &lt;strong&gt;Copy embed code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5bbzg20517hnbqb4pjb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5bbzg20517hnbqb4pjb.png" alt="Screenshot of the Embed tab with the two-line code block and the Copy embed code button" width="800" height="911"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Build the Restaurant Website HTML
&lt;/h2&gt;

&lt;p&gt;Now you build the full restaurant website. &lt;br&gt;
The menu section uses the embed code you just &lt;br&gt;
copied.&lt;/p&gt;

&lt;p&gt;Create a new file called &lt;code&gt;index.html&lt;/code&gt; and paste &lt;br&gt;
in this complete template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Allen's Restaurant, Little Italy, New York&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Allen's Restaurant has been serving Little Italy since 1998. Handmade pasta, wood-fired pizza, and a table when you need one. Walk in or reserve."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.googleapis.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.gstatic.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,400;0,500;0,600;0,700;1,400&amp;amp;family=Playfair+Display:ital,wght@0,400;0,500;0,600;1,400;1,500&amp;amp;display=swap"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="py"&gt;--paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#F3EDE2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--paper-2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#E8DFD0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1A1410&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--ink-soft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#5A5248&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--wine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6E1C22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--wine-dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4E1318&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--olive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3D4A32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#C9BBA5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--on-wine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#F3EDE2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Playfair Display"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Georgia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;"Times New Roman"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--font-body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Karla"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200ms&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--z-nav&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--z-menu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-2xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;48px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--space-3xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1120px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;--gutter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2.5rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="o"&gt;*,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;::before&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="py"&gt;scroll-behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;smooth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;scroll-behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="o"&gt;*,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;::before&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;animation-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01ms&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;animation-iteration-count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;transition-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01ms&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--paper&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.65&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;-webkit-font-smoothing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;antialiased&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.035&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)&lt;/span&gt;&lt;span class="s2"&gt;'/%3E%3C/svg%3E");
    }

    img {
      max-width: 100%;
      display: block;
    }

    a, button, label, select, summary {
      cursor: pointer;
    }

    a:focus-visible,
    button:focus-visible,
    input:focus-visible,
    select:focus-visible,
    textarea:focus-visible {
      outline: 2px solid var(--wine);
      outline-offset: 3px;
    }

    .skip-link {
      position: absolute;
      left: var(--gutter);
      top: -100px;
      z-index: 50;
      background: var(--wine);
      color: var(--on-wine);
      padding: 0.75rem 1.25rem;
      font-size: 0.875rem;
      font-weight: 600;
      text-decoration: none;
    }

    .skip-link:focus {
      top: 1rem;
    }

    .wrap {
      width: min(var(--content), calc(100% - var(--gutter) * 2));
      margin-inline: auto;
    }

    #about,
    #menu,
    #reserve,
    #visit,
    #main {
      scroll-margin-top: 80px;
    }

    /* Nav */
    .site-header {
      position: sticky;
      top: 0;
      z-index: var(--z-nav);
      background: var(--paper);
      border-bottom: 1px solid var(--rule);
    }

    .nav {
      display: flex;
      align-items: center;
      justify-content: space-between;
      height: 72px;
      gap: var(--space-lg);
    }

    .nav-panel {
      margin-left: auto;
    }

    .brand {
      font-family: var(--font-display);
      font-size: 1.625rem;
      font-style: italic;
      font-weight: 500;
      color: var(--ink);
      text-decoration: none;
      line-height: 1;
    }

    .brand span {
      display: block;
      font-family: var(--font-body);
      font-style: normal;
      font-size: 0.6875rem;
      font-weight: 500;
      letter-spacing: 0.12em;
      color: var(--ink-soft);
      margin-top: 4px;
    }

    .nav-links {
      display: flex;
      align-items: center;
      gap: 2rem;
      list-style: none;
    }

    .nav-links a {
      color: var(--ink);
      text-decoration: none;
      font-size: 0.9375rem;
      font-weight: 500;
      transition: color var(--ease);
    }

    .nav-links a:hover {
      color: var(--wine);
    }

    .nav-links a.nav-reserve {
      display: inline-block;
      background: var(--wine);
      color: var(--on-wine);
      text-decoration: none;
      font-size: 0.8125rem;
      font-weight: 600;
      padding: 0.7rem 1.15rem;
      border-radius: 2px;
      transition: background var(--ease);
    }

    .nav-links a.nav-reserve:hover {
      background: var(--wine-dark);
      color: var(--on-wine);
    }

    .nav-toggle {
      display: none;
      width: 44px;
      height: 44px;
      border: 0;
      background: transparent;
      position: relative;
    }

    .nav-toggle span,
    .nav-toggle span::before,
    .nav-toggle span::after {
      display: block;
      width: 20px;
      height: 1.5px;
      background: var(--ink);
      position: absolute;
      left: 12px;
      transition: transform var(--ease), opacity var(--ease);
    }

    .nav-toggle span { top: 21px; }
    .nav-toggle span::before,
    .nav-toggle span::after {
      content: "";
      left: 0;
    }
    .nav-toggle span::before { top: -6px; }
    .nav-toggle span::after { top: 6px; }

    .nav-toggle[aria-expanded="true"] span {
      background: transparent;
    }
    .nav-toggle[aria-expanded="true"] span::before {
      top: 0;
      transform: rotate(45deg);
    }
    .nav-toggle[aria-expanded="true"] span::after {
      top: 0;
      transform: rotate(-45deg);
    }

    /* Hero */
    .hero {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
      min-height: calc(100svh - 72px);
      border-bottom: 1px solid var(--rule);
    }

    .hero-copy {
      display: flex;
      flex-direction: column;
      justify-content: center;
      padding: clamp(2.5rem, 6vw, 5rem) 2.5rem clamp(2.5rem, 5vw, 4rem) max(var(--gutter), calc((100vw - var(--content)) / 2));
    }

    .hero-copy p.lede {
      font-size: 0.875rem;
      color: var(--olive);
      font-weight: 600;
      margin-bottom: 1.25rem;
    }

    .hero h1 {
      font-family: var(--font-display);
      font-weight: 500;
      font-size: clamp(2.5rem, 5.4vw, 4.25rem);
      line-height: 1.12;
      letter-spacing: -0.02em;
      margin-bottom: 1.25rem;
    }

    .hero h1 em {
      font-style: italic;
      font-weight: 400;
    }

    .hero-copy .intro {
      font-size: 1.125rem;
      color: var(--ink-soft);
      max-width: 28rem;
      margin-bottom: 2rem;
    }

    .hero-actions {
      display: flex;
      align-items: center;
      gap: 1.5rem;
      flex-wrap: wrap;
    }

    .btn {
      display: inline-block;
      background: var(--wine);
      color: var(--on-wine);
      text-decoration: none;
      font-size: 0.9375rem;
      font-weight: 600;
      padding: 0.9rem 1.5rem;
      border: 0;
      border-radius: 2px;
      font-family: inherit;
      transition: background var(--ease);
    }

    .btn:hover {
      background: var(--wine-dark);
    }

    .text-link {
      color: var(--ink);
      font-size: 0.9375rem;
      font-weight: 600;
      text-decoration: none;
      border-bottom: 1px solid var(--ink);
      padding-bottom: 2px;
      transition: color var(--ease), border-color var(--ease);
    }

    .text-link:hover {
      color: var(--wine);
      border-color: var(--wine);
    }

    .hero-photo {
      min-height: 320px;
      overflow: hidden;
    }

    .hero-photo img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      min-height: 100%;
    }

    /* About */
    .about {
      padding: var(--space-3xl) 0;
    }

    .about-grid {
      display: grid;
      grid-template-columns: 1.05fr 0.95fr;
      gap: clamp(2rem, 5vw, 4.5rem);
      align-items: center;
    }

    .about-photo {
      overflow: hidden;
    }

    .about-photo img {
      width: 100%;
      height: 100%;
      aspect-ratio: 4 / 5;
      object-fit: cover;
    }

    .about-photo figcaption {
      font-size: 0.75rem;
      color: var(--ink-soft);
      margin-top: 0.75rem;
    }

    .about h2 {
      font-family: var(--font-display);
      font-weight: 500;
      font-size: clamp(2rem, 3.5vw, 2.75rem);
      line-height: 1.2;
      margin-bottom: 1.25rem;
    }

    .about h2 em {
      font-style: italic;
      font-weight: 400;
    }

    .about p {
      color: var(--ink-soft);
      font-size: 1.0625rem;
      margin-bottom: 1rem;
    }

    .pull {
      font-family: var(--font-display);
      font-style: italic;
      font-size: 1.375rem;
      line-height: 1.45;
      color: var(--ink);
      margin: 1.75rem 0 0;
      padding-top: 1.5rem;
      border-top: 1px solid var(--rule);
    }

    /* Visit band */
    .visit {
      background: var(--wine);
      color: var(--on-wine);
      padding: 3.5rem 0;
    }

    .visit-grid {
      display: grid;
      grid-template-columns: 1.2fr repeat(3, 1fr);
      gap: 2rem;
      align-items: start;
    }

    .visit h2 {
      font-family: var(--font-display);
      font-weight: 500;
      font-size: clamp(1.75rem, 3vw, 2.25rem);
      line-height: 1.2;
    }

    .visit h2 em {
      font-style: italic;
      font-weight: 400;
    }

    .visit h3 {
      font-size: 0.75rem;
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: rgba(243, 237, 226, 0.65);
      margin-bottom: 0.5rem;
    }

    .visit p,
    .visit a {
      font-size: 1rem;
      color: var(--on-wine);
      text-decoration: none;
      line-height: 1.55;
    }

    .visit a:hover {
      text-decoration: underline;
    }

    /* Menu */
    .menu {
      padding: var(--space-3xl) 0;
    }

    .menu-header {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 2rem;
      align-items: end;
      margin-bottom: 2.5rem;
      padding-bottom: 1.5rem;
      border-bottom: 1px solid var(--rule);
    }

    .menu h2 {
      font-family: var(--font-display);
      font-weight: 500;
      font-size: clamp(2rem, 3.5vw, 2.75rem);
    }

    .menu-header p {
      color: var(--ink-soft);
      font-size: 1.0625rem;
    }

    /* Reserve */
    .reserve {
      padding: 0 0 var(--space-3xl);
    }

    .reserve-grid {
      display: grid;
      grid-template-columns: 0.85fr 1.15fr;
      gap: clamp(2rem, 5vw, 4.5rem);
      padding-top: var(--space-3xl);
      border-top: 1px solid var(--rule);
    }

    .reserve h2 {
      font-family: var(--font-display);
      font-weight: 500;
      font-size: clamp(2rem, 3.5vw, 2.75rem);
      margin-bottom: 1rem;
    }

    .reserve .lede {
      color: var(--ink-soft);
      font-size: 1.0625rem;
      margin-bottom: 1.5rem;
    }

    .reserve-note {
      font-size: 0.9375rem;
      color: var(--ink-soft);
    }

    .form-row {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 1.25rem;
    }

    .field {
      margin-bottom: 1.25rem;
    }

    .field label {
      display: block;
      font-size: 0.8125rem;
      font-weight: 600;
      margin-bottom: 0.4rem;
    }

    .field input,
    .field select,
    .field textarea {
      width: 100%;
      font: 400 1rem/1.4 var(--font-body);
      color: var(--ink);
      background: transparent;
      border: 0;
      border-bottom: 1px solid var(--rule);
      border-radius: 0;
      padding: 0.65rem 0;
      appearance: none;
    }

    .field select {
      background-image: url("data:image/svg+xml,%3Csvg xmlns='&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;://&lt;/span&gt;&lt;span class="n"&gt;www&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="s2"&gt;' width='&lt;/span&gt;&lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="s2"&gt;' height='&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="s2"&gt;' viewBox='&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="s2"&gt;'%3E%3Cpath fill='&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="s2"&gt;' stroke='&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="m"&gt;231&lt;/span&gt;&lt;span class="n"&gt;A1410&lt;/span&gt;&lt;span class="s2"&gt;' stroke-width='&lt;/span&gt;&lt;span class="m"&gt;1.4&lt;/span&gt;&lt;span class="s2"&gt;' d='&lt;/span&gt;&lt;span class="n"&gt;M1&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;l5&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="m"&gt;5-5&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;background-repeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;padding-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.field&lt;/span&gt; &lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.field&lt;/span&gt; &lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:focus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;.field&lt;/span&gt; &lt;span class="nt"&gt;select&lt;/span&gt;&lt;span class="nd"&gt;:focus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;.field&lt;/span&gt; &lt;span class="nt"&gt;textarea&lt;/span&gt;&lt;span class="nd"&gt;:focus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;border-bottom-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--wine&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.form-status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;font-style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;italic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.375rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.form-status.is-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="nc"&gt;.is-sent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;/* Footer */&lt;/span&gt;
    &lt;span class="nt"&gt;footer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--rule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.footer-inner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;space-between&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;flex-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.footer-inner&lt;/span&gt; &lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.875rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ink-soft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.footer-inner&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.footer-inner&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--wine&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;860px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;.nav-toggle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.nav-panel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;72px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--z-menu&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--paper&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--rule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--gutter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.nav-panel.is-open&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.nav-links&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex-start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.nav-reserve&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.hero-photo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;46vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;240px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.hero-copy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--gutter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;2.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.about-grid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;.menu-header&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;.reserve-grid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;.form-row&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.about-photo&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.visit-grid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nc"&gt;.visit-grid&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;:first-child&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;grid-column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;520px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;.visit-grid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skip-link"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#main"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Skip to content&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;header&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"site-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap nav"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"brand"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        Allen's
        &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;Restaurant · Est. 1998&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;

      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"nav-toggle"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;aria-expanded=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;aria-controls=&lt;/span&gt;&lt;span class="s"&gt;"nav-panel"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Open menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"nav-panel"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"nav-panel"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"nav-links"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#about"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Menu&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#reserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reservations&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"nav-reserve"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#reserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reserve a table&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"main"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"hero"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Introduction"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"hero-copy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"lede"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;123 Pasta Lane, Little Italy&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Pasta rolled this morning. &lt;span class="nt"&gt;&amp;lt;em&amp;gt;&lt;/span&gt;Sauce from yesterday.&lt;span class="nt"&gt;&amp;lt;/em&amp;gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"intro"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          A neighborhood kitchen for twenty-five years. Handmade pasta, wood-fired pizza, and a table when you need one.
        &lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"hero-actions"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#reserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reserve a table&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-link"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;See the menu&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"hero-photo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt;
          &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://images.unsplash.com/photo-1552566626-52f8b828add9?w=1600&amp;amp;q=80"&lt;/span&gt;
          &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"The dining room at Allen's Restaurant"&lt;/span&gt;
          &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1600"&lt;/span&gt;
          &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;
          &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"about"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"about"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap about-grid"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;figure&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"about-photo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt;
            &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=1200&amp;amp;q=80"&lt;/span&gt;
            &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"The dining room at Allen's Restaurant"&lt;/span&gt;
            &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;
            &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"1500"&lt;/span&gt;
            &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;figcaption&amp;gt;&lt;/span&gt;The dining room on a Tuesday. Come as you are.&lt;span class="nt"&gt;&amp;lt;/figcaption&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/figure&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Three generations in &lt;span class="nt"&gt;&amp;lt;em&amp;gt;&lt;/span&gt;one kitchen.&lt;span class="nt"&gt;&amp;lt;/em&amp;gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;
            Allen's Restaurant has been on Pasta Lane since 1998. Every dish is made from scratch, from recipes that started in a family kitchen outside Naples and settled here.
          &lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;
            Tomatoes come from a family farm in Campania. The cheese is from a producer two hours upstate. The pasta is rolled every morning. If we run out, we run out.
          &lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pull"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            “The tagliatelle is what my nonna would have made, if she had a wood oven in Little Italy.”
          &lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"visit"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"visit"&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby=&lt;/span&gt;&lt;span class="s"&gt;"visit-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap visit-grid"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"visit-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Walk in, or &lt;span class="nt"&gt;&amp;lt;em&amp;gt;&lt;/span&gt;call ahead.&lt;span class="nt"&gt;&amp;lt;/em&amp;gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Address&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;123 Pasta Lane&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;Little Italy&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;New York, NY 10013&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Hours&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Monday–Friday&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;12pm–10pm&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;Saturday–Sunday&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;11am–11pm&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Phone&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"tel:+12125550142"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;(212) 555-0142&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"menu"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"menu-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;What's cooking&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Made to order. Add to your cart and we'll have it ready for pickup or delivery.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
          &lt;span class="na"&gt;data-sheetrocket=&lt;/span&gt;&lt;span class="s"&gt;"cmt9z2b4a0008x8sw6hrif38p"&lt;/span&gt;
          &lt;span class="na"&gt;data-widget=&lt;/span&gt;&lt;span class="s"&gt;"menu-widget"&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://sheetrocket.com/catalog-widget.js"&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"reserve"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"reserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap reserve-grid"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Reserve a table&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"lede"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            Tell us when you're coming. We'll write back to confirm. Weekends fill up, so a day's notice helps.
          &lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"reserve-note"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            For parties of eight or more, call the restaurant directly.
          &lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-status"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form-status"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"reserve-form"&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;novalidate&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Name&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Email&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"date"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Date&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"date"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"date"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"date"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"time"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Time&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;select&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"time"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"time"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Choose a time&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;12:00 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;12:30 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;1:00 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;1:30 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;6:00 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;6:30 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;7:00 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;7:30 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;8:00 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;8:30 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;9:00 pm&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;/select&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"party"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Party size&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;select&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"party"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"party"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;How many&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;2&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;3&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;4&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;5&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;6&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;7&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;/select&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Phone&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"notes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Anything we should know?&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"notes"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"notes"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Allergies, a birthday, a quieter table…"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send request&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;footer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap footer-inner"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Allen's Restaurant · Little Italy · Est. 1998&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"tel:+12125550142"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;(212) 555-0142&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/footer&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;toggle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.nav-toggle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nav-panel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;toggle&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-expanded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Close menu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Open menu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-expanded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;})();&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reserve-form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form-status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;dateField&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateField&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;dateField&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkValidity&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reportValidity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sending…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thanks. We'll confirm your table by email shortly.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;})();&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;YOUR-WIDGET-ID&lt;/code&gt; in the embed code with &lt;br&gt;
the actual widget ID from your SheetRocket &lt;br&gt;
Embed tab.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 10: Test the Full Website
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;index.html&lt;/code&gt; in your browser. You should see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A full restaurant website with hero, about, 
features, menu, contact, and footer&lt;/li&gt;
&lt;li&gt;Your live menu pulling data from your Google Sheet&lt;/li&gt;
&lt;li&gt;Search and category filter working on the menu&lt;/li&gt;
&lt;li&gt;Add to cart buttons on each menu card&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faimksw0npblx34mamzci.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faimksw0npblx34mamzci.png" alt="Screenshot of the complete restaurant website in a browser with the hero section showing" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvkroqsjrbyadv9fsned.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvkroqsjrbyadv9fsned.png" alt="Screenshot of the complete restaurant website in a browser with the menu section showing" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click the add-to-cart button on a menu item to add it &lt;br&gt;
to the cart. A floating cart button will appear &lt;br&gt;
at the top right of the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9shsmjgwreg4tpat57db.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9shsmjgwreg4tpat57db.png" alt="Screenshot of a food card with the circular cart button visible in the bottom right corner of the card" width="800" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click the floating cart button to open the cart &lt;br&gt;
modal. You will see your selected items, a quantity &lt;br&gt;
control, and a checkout button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvwzwzxyes1tcon0yh9a4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvwzwzxyes1tcon0yh9a4.png" alt="Screenshot of the cart modal open with two items, quantities, a total, and a Checkout button" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Checkout&lt;/strong&gt; and fill in the order form with &lt;br&gt;
a name, email, phone number, and address. Click &lt;br&gt;
&lt;strong&gt;Place order&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctnsd6e3kmxwkn4yw4op.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctnsd6e3kmxwkn4yw4op.png" alt="Screenshot of the checkout form with name, email, phone, and address fields filled in" width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhil4i4vsrs9uym90rhg7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhil4i4vsrs9uym90rhg7.png" alt="Screenshot of the confirmation dialog after checkout" width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Within seconds, the restaurant owner receives &lt;br&gt;
an order notification email with the full &lt;br&gt;
order details.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9vnudgv3gs3i5c7le1ex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9vnudgv3gs3i5c7le1ex.png" alt="Screenshot of the order notification email in an inbox showing customer name, items ordered, and total" width="799" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The customer also receives a confirmation email &lt;br&gt;
automatically.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 11: Test the Live Update
&lt;/h2&gt;

&lt;p&gt;This is the part that makes SheetRocket worth &lt;br&gt;
using. Go back to your Google Sheet and change &lt;br&gt;
a price. Update "Margherita Pizza" from $12.99 &lt;br&gt;
to $13.99.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtechjdxv78urvbztpv4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtechjdxv78urvbztpv4.png" alt="Screenshot of the Google Sheet with the Margherita Pizza price being changed from 12.99 to 13.99" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now refresh your HTML page.&lt;/p&gt;

&lt;p&gt;The updated price appears on the menu immediately. &lt;br&gt;
No website edit. No redeploy. No developer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmu27dlsrsnetxa7zr0i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmu27dlsrsnetxa7zr0i.png" alt="Screenshot of the menu widget showing the updated price of $13.99 on the Margherita Pizza card" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add a new dish to your Google Sheet by adding &lt;br&gt;
a new row at the bottom. Fill in the name, price, category, description, and image URL. Set Available to Yes.&lt;/p&gt;

&lt;p&gt;Refresh the page. The new dish appears on the menu.&lt;/p&gt;

&lt;p&gt;Remove a dish by deleting the row from your sheet. &lt;br&gt;
Refresh the page. It is gone from the menu.&lt;/p&gt;

&lt;p&gt;The restaurant owner can manage the entire menu &lt;br&gt;
from their Google Sheet without ever touching &lt;br&gt;
the website.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 12: Deploy the Website
&lt;/h2&gt;

&lt;p&gt;Your HTML file is a static website. It can be &lt;br&gt;
hosted for free on any static hosting platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Pages (free):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a new GitHub repository&lt;/li&gt;
&lt;li&gt;Upload your &lt;code&gt;index.html&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;Go to Settings then Pages&lt;/li&gt;
&lt;li&gt;Set the source to the main branch&lt;/li&gt;
&lt;li&gt;Your site is live at 
&lt;code&gt;yourusername.github.io/repository-name&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Netlify (free):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to netlify.com and create a free account&lt;/li&gt;
&lt;li&gt;Drag your &lt;code&gt;index.html&lt;/code&gt; file onto the 
Netlify deploy area&lt;/li&gt;
&lt;li&gt;Your site is live instantly at a 
random Netlify URL&lt;/li&gt;
&lt;li&gt;Connect a custom domain in settings&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Vercel (free):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to vercel.com and create a free account&lt;/li&gt;
&lt;li&gt;Import your GitHub repository or drag 
your file&lt;/li&gt;
&lt;li&gt;Your site is live at a Vercel URL&lt;/li&gt;
&lt;li&gt;Connect a custom domain in settings&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a custom domain like &lt;code&gt;mariosrestaurant.com&lt;/code&gt; &lt;br&gt;
point your domain's DNS to your hosting provider &lt;br&gt;
and add it in their dashboard.&lt;/p&gt;


&lt;h2&gt;
  
  
  Live Demo
&lt;/h2&gt;

&lt;p&gt;The menu below is pulling live data from a &lt;br&gt;
Google Sheet. Search for dishes, filter by &lt;br&gt;
category, and add items to your cart to see &lt;br&gt;
the full ordering flow in action.&lt;/p&gt;



&lt;p&gt;
  data-sheetrocket="DEMO-WIDGET-ID"&lt;br&gt;
  data-template="menu-widget"&lt;br&gt;
  data-theme="light"&lt;br&gt;
  data-columns="3"&amp;gt;&lt;br&gt;
&lt;br&gt;

  src="https://sheetrocket.com/widget.js"&lt;br&gt;
  async&amp;gt;&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qe6n4yxwewvnj6uangl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qe6n4yxwewvnj6uangl.png" alt="Screenshot of the live demo section on the blog post showing the SheetRocket menu widget with pizza and pasta cards visible" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Restaurant Owner Gets
&lt;/h2&gt;

&lt;p&gt;Once this is set up, the restaurant owner has &lt;br&gt;
complete control over their menu without &lt;br&gt;
touching the website:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Change a price:
  Edit one cell in Google Sheets
  Website updates automatically

Add a new dish:
  Add one row to the sheet
  Dish appears on the website

Remove a dish:
  Delete the row from the sheet
  Dish disappears from the website

Mark something as unavailable:
  Change Available from Yes to No
  Dish shows as unavailable
  or disappears from the menu

Add a daily special:
  Add a row with today's date
  Remove it at end of day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No login to a website builder. No calling &lt;br&gt;
a developer. No waiting for someone to make the change. Just edit the spreadsheet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Taking It Further
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add a second location:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Create a second widget for the second location &lt;br&gt;
connected to a different tab in the same Google &lt;br&gt;
Sheet. Embed it on a second page or on the &lt;br&gt;
same page below the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seasonal menus:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Create a Summer Menu tab and a Winter Menu tab &lt;br&gt;
in your sheet. Switch the widget between tabs by changing one setting in SheetRocket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily specials:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add a Specials tab to your sheet. Create a &lt;br&gt;
second widget connected to that tab. Embed &lt;br&gt;
it above the main menu with a "Today's specials" &lt;br&gt;
heading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple languages:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Duplicate your sheet tab for each language. &lt;br&gt;
Create one widget per language. Show the &lt;br&gt;
correct widget based on a language selector &lt;br&gt;
in your HTML.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;A hardcoded restaurant menu is a maintenance &lt;br&gt;
problem waiting to happen. Every price change, every seasonal update, every dish removed requires a developer or someone comfortable &lt;br&gt;
editing HTML.&lt;/p&gt;

&lt;p&gt;A SheetRocket-powered menu puts control back &lt;br&gt;
with the restaurant owner. They already know &lt;br&gt;
how to use Google Sheets. This tutorial adds a live website connection to the spreadsheet they are already maintaining.&lt;/p&gt;

&lt;p&gt;The website is plain HTML. It loads fast, &lt;br&gt;
works on any hosting, and costs nothing to deploy. The menu updates automatically the moment the sheet is edited. Online orders arrive by email. No monthly platform fees. No complex integrations.&lt;/p&gt;

&lt;p&gt;If you are building this for a restaurant &lt;br&gt;
client, SheetRocket's Agency plan at $39/month covers unlimited restaurant sites. &lt;br&gt;
Each client manages their own Google Sheet. You manage everything from one SheetRocket account.&lt;/p&gt;

&lt;p&gt;Start free at &lt;a href="https://sheetrocket.com" rel="noopener noreferrer"&gt;sheetrocket.com&lt;/a&gt;. &lt;br&gt;
No credit card required.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I am the founder of SheetRocket, &lt;br&gt;
a tool that turns Google Sheets into websites, &lt;br&gt;
embeddable widgets, and REST APIs. Currently &lt;br&gt;
building in public from Ghana.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>html</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What Actually Makes a Software Engineer Great (It's Not the Framework You Know)</title>
      <dc:creator>Allen Jones</dc:creator>
      <pubDate>Tue, 25 Aug 2026 18:50:15 +0000</pubDate>
      <link>https://dev.to/allenarduino/what-actually-makes-a-software-engineer-great-its-not-the-framework-you-know-2m3l</link>
      <guid>https://dev.to/allenarduino/what-actually-makes-a-software-engineer-great-its-not-the-framework-you-know-2m3l</guid>
      <description>&lt;p&gt;Learn React. Learn a backend language. Build projects. Apply to jobs. That's the advice every developer who wants to level up hears. That advice isn't wrong, but it misses the thing that actually separates a senior engineer from someone who's been writing code for six months. It's not the framework in your resume. It's whether you understand why that framework exists in the first place, and whether you can reason about a problem well enough to pick the right tool instead of the familiar one.&lt;/p&gt;

&lt;p&gt;Here are the three things that actually move the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Understand the "Why," Not Just the "How"
&lt;/h2&gt;

&lt;p&gt;If you're learning to code today, the common advice is "learn React." Fair enough, React is popular and in demand. But if you don't understand why React had to exist, how frontend development worked before it, and what problem it was solving, you're going to hit a ceiling.&lt;/p&gt;

&lt;p&gt;A quick example: before &lt;code&gt;async/await&lt;/code&gt; existed, asynchronous JavaScript was written with nested callbacks, and once you had a few dependent async operations in a row, that nesting turned into what everyone just called callback hell. Promises came along to flatten that out, and &lt;code&gt;async/await&lt;/code&gt; came after to make asynchronous code read almost like synchronous code. If you only learn &lt;code&gt;async/await&lt;/code&gt; as "the syntax you use for async stuff," you'll be lost the moment you open an older codebase full of &lt;code&gt;.then()&lt;/code&gt; chains or raw callbacks. Knowing what problem it replaced is what lets you actually read and work in code that predates it.&lt;/p&gt;

&lt;p&gt;This matters because tools get chosen for reasons, and those reasons change depending on the problem. A senior engineer doesn't reach for React (or Rails, or Django, or whatever is trending) by default. They look at what the project actually needs to solve, now and as it grows, and pick technology based on that.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Concrete Example: Node.js vs Python
&lt;/h3&gt;

&lt;p&gt;This "understand the why" idea gets a lot more concrete when you compare Node.js and Python, because the two languages handle concurrency in fundamentally different ways, and that difference decides what each one is actually good at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node.js runs on a single thread with a non-blocking event loop.&lt;/strong&gt; When a request comes in that needs to read a file, hit a database, or call another API, Node doesn't block that thread waiting for the response. It hands the operation off (to libuv under the hood), keeps handling other requests, and comes back to yours when the data is ready.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node.js: this doesn't block the event loop&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/user/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes Node excellent at I/O bound workloads: REST APIs, real-time apps (chat, live notifications via WebSockets), streaming services, and anything where a request spends most of its time waiting on the network or disk rather than doing heavy computation. A single Node process can juggle thousands of concurrent connections because it isn't burning CPU cycles or a full thread stack per connection.&lt;/p&gt;

&lt;p&gt;Where Node struggles is CPU-bound work. Because everything runs on that one thread, a heavy synchronous computation (image processing, video encoding, complex encryption, large in-memory data crunching) blocks the event loop entirely. Every other request waiting on that same process just freezes until the computation finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This blocks every other request until it finishes&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;heavyImageProcessing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// blocks the event loop&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node does have &lt;code&gt;worker_threads&lt;/code&gt; and &lt;code&gt;child_process&lt;/code&gt; to push CPU-heavy work off the main thread, but that's a workaround you have to reach for deliberately; it's not the default execution model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python's story is almost the opposite.&lt;/strong&gt; Python has something called the GIL, the Global Interpreter Lock. It means that even if you spin up multiple threads in a single Python process, only one thread can execute Python bytecode at a time. So naive multi-threading in Python does not give you true parallelism for CPU-bound work. Adding more threads to a CPU-heavy Python task usually doesn't make it faster, because they're all waiting their turn for the same lock.&lt;/p&gt;

&lt;p&gt;This is why Python isn't the first choice for something like a high-concurrency real-time API server written from scratch, at least not without help. Pure multi-threaded Python doesn't scale CPU work the way you'd expect.&lt;/p&gt;

&lt;p&gt;But this is also where it gets interesting, because Python is still the dominant language in data science and machine learning, and that's not a contradiction. Two things make it work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;I/O bound work still benefits from Python's concurrency tools.&lt;/strong&gt; The GIL is released while a thread is waiting on I/O (network calls, file reads), so Python's &lt;code&gt;threading&lt;/code&gt; module or &lt;code&gt;asyncio&lt;/code&gt; (which mirrors Node's event loop model) still works well for I/O heavy workloads like web scraping or calling multiple APIs concurrently.&lt;/li&gt;
&lt;li&gt;** CPU-heavy libraries do their real work outside the GIL.** Libraries like NumPy, pandas, and PyTorch are built on C, C++, or CUDA under the hood. When you call a NumPy operation on a large array, Python hands the actual number crunching to compiled code that releases the GIL and can run in parallel or on a GPU. Python itself is just the orchestration layer; the heavy lifting happens outside the interpreter entirely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For true CPU-bound parallelism in pure Python (not delegated to a C extension), you reach for &lt;code&gt;multiprocessing&lt;/code&gt;, which spins up separate processes, each with its own interpreter and its own GIL, and splits the work across CPU cores. It works, but it's heavier than threading because each process has its own memory space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where each one fits:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Good fit&lt;/th&gt;
&lt;th&gt;Weak fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node.js&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;APIs and microservices with high concurrent I/O, real-time apps, streaming, chat backends, thin services with light CPU work&lt;/td&gt;
&lt;td&gt;Heavy synchronous computation, video/image processing, CPU-heavy encryption, ML model training&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data science, machine learning, scientific computing, scripting and automation, I/O bound work via asyncio, backends where the ecosystem (FastAPI, Django) fits&lt;/td&gt;
&lt;td&gt;Raw CPU-bound parallelism using plain threads, extremely high-concurrency real-time servers without async tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither language is "better." They were built with different priorities, and picking between them for a project means understanding what kind of work that project actually does, not just which one you're more comfortable with. That's the same lesson as the React example, just at the language level instead of the framework level.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters More With AI Tools
&lt;/h3&gt;

&lt;p&gt;This "understand the why" skill matters even more now that AI coding agents can write most of the code for you. There's a real difference between telling an AI agent "add authentication to this app" and being able to tell it whether to use sessions or JWTs, how long tokens should live, whether refresh tokens belong in an httpOnly cookie or local storage, and what happens to existing logged-in users when the auth flow changes.&lt;/p&gt;

&lt;p&gt;The AI is doing the typing. The decisions still have to come from you. If your AI agent can make a technical decision without you being able to explain why it was the right one, that's worth noticing, because it means the reasoning skill isn't being built, only the code is.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Product Engineering: Go Beyond Writing Code
&lt;/h2&gt;

&lt;p&gt;The role of a software engineer is shifting. It used to be enough to be handed a spec and implement it well. Increasingly, engineers are expected to be part of figuring out what should be built in the first place.&lt;/p&gt;

&lt;p&gt;That means being comfortable with things that used to sit entirely with product managers: talking to users to understand the actual problem they're facing, translating that into a roadmap or a set of tickets, tying the work back to a business goal, and collaborating with product managers and designers on how the solution should work before a single line of code gets written.&lt;/p&gt;

&lt;p&gt;Once that planning is done, handing the implementation over to an AI agent (or just writing it yourself) becomes the easy part. The hard, valuable part is everything that happens before that: understanding the problem well enough to know what "done" actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. System Design: Know What's Underneath the Platform
&lt;/h2&gt;

&lt;p&gt;A lot of developers can push code to a managed hosting platform and have it live in minutes without ever thinking about what's happening behind that button. That's completely fine for shipping fast, but it becomes a real gap the moment you're handed infrastructure that nobody has already wired up for you.&lt;/p&gt;

&lt;p&gt;Picture a different scenario: you're told your application needs to handle ten times its current traffic with no downtime. Would you know whether the fix is adding more app servers behind a load balancer, moving to a bigger single server, or introducing a caching layer so the database stops doing repeated work? If terms like reverse proxy, connection pooling, or cache invalidation feel fuzzy, that's less about any one tool and more about a gap in system design fundamentals.&lt;/p&gt;

&lt;p&gt;The fundamentals worth understanding here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The difference between horizontal scaling (more machines) and vertical scaling (bigger machines), and when each makes sense&lt;/li&gt;
&lt;li&gt;What a load balancer and a reverse proxy actually do, and how requests get routed across multiple app servers&lt;/li&gt;
&lt;li&gt;Private networking and secure communication between services&lt;/li&gt;
&lt;li&gt;Database security, connection pooling, and access patterns at scale&lt;/li&gt;
&lt;li&gt;Managing infrastructure through code and version control versus clicking through a cloud dashboard by hand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using a managed hosting platform because it's fast and convenient is a completely reasonable choice. The problem is only when it's the only thing you know how to do, because the moment a project needs something that platform doesn't offer, you're stuck without the fundamentals to build it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;None of these three things (understanding the fundamentals behind your tools, thinking like a product engineer, and understanding system design) show up on a "learn React in 30 days" course outline. They're slower to build and harder to measure. But they're also the actual difference between an engineer who can be handed any problem and figure it out, and one who can only work within the exact stack and constraints they were taught.&lt;/p&gt;

&lt;p&gt;If you're early in your career, you don't need to master all three at once. Start by picking apart one tool or framework you already use every day and asking why it exists, what problem it solved, and what the alternative would have looked like without it. That habit, repeated across enough tools, is what builds the kind of judgment that AI can't replace for you.&lt;/p&gt;

</description>
      <category>softwareengineer</category>
      <category>dev</category>
      <category>backend</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
