<?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: Brightcode</title>
    <description>The latest articles on DEV Community by Brightcode (@brightcode).</description>
    <link>https://dev.to/brightcode</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%2F4188562%2F184c69f3-3049-4c06-b9c7-6c13edbcff7a.jpg</url>
      <title>DEV Community: Brightcode</title>
      <link>https://dev.to/brightcode</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/brightcode"/>
    <language>en</language>
    <item>
      <title>Your contact form says "Thank you". Did the lead arrive? How we test website forms end to end</title>
      <dc:creator>Brightcode</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:20:56 +0000</pubDate>
      <link>https://dev.to/brightcode/your-contact-form-says-thank-you-did-the-lead-arrive-how-we-test-website-forms-end-to-end-19ji</link>
      <guid>https://dev.to/brightcode/your-contact-form-says-thank-you-did-the-lead-arrive-how-we-test-website-forms-end-to-end-19ji</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Brightcode team, a small web agency in Delhi NCR. Reviewed by Gaurav Khokkar, Founder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most expensive bug we look for on a client site doesn't throw an error. The form submits, the visitor sees a green "Thanks, we'll be in touch", and the lead goes nowhere. It lands in a spam folder, or in the inbox of someone who left last year, or it never leaves the server at all.&lt;/p&gt;

&lt;p&gt;Nobody reports it, because the person it fails for has already gone off to call a competitor. The owner only notices months later, when enquiries look "a bit slow".&lt;/p&gt;

&lt;p&gt;So for every site we build or take over, we treat the lead path as a system that needs tests, the same as a checkout. Here's what that looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: List every lead path, not just the contact page
&lt;/h2&gt;

&lt;p&gt;A business site rarely has one form. We write them all down before touching code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Contact and "get a quote" forms&lt;/li&gt;
&lt;li&gt;Popup or footer forms (often from a different plugin than the main one)&lt;/li&gt;
&lt;li&gt;Phone links (&lt;code&gt;tel:&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;WhatsApp buttons&lt;/li&gt;
&lt;li&gt;Email links (&lt;code&gt;mailto:&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Booking widgets, newsletter sign-ups, careers forms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each one, note where the submission is supposed to end up (which inbox, CRM or sheet) and who is supposed to answer it. That second column is where the surprises are. More than once, the honest answer has been "nobody knows".&lt;/p&gt;

&lt;p&gt;Check the links while you're at it. On our own site, the WhatsApp button used to be a JavaScript click handler with no real link behind it. It worked for most visitors, but it isn't a link to a crawler, to some in-app browsers, or to anyone with a script blocked. Now it's a plain &lt;code&gt;&amp;lt;a href="https://wa.me/..."&amp;gt;&lt;/code&gt;. Boring, and it always works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Stop relying on &lt;code&gt;mail()&lt;/code&gt; and the host's default sender
&lt;/h2&gt;

&lt;p&gt;The classic setup is a form plugin that calls PHP's &lt;code&gt;mail()&lt;/code&gt; on shared hosting and sends as &lt;code&gt;wordpress@yourdomain.com&lt;/code&gt;. That message usually has no proper authentication for your domain, and receiving servers are much stricter than they used to be. Google's sender guidelines say that since February 2024 &lt;strong&gt;all&lt;/strong&gt; senders to Gmail accounts must set up SPF or DKIM, and bulk senders need SPF, DKIM and DMARC.&lt;/p&gt;

&lt;p&gt;A small business form sends a few emails a day, nowhere near bulk. It still makes sense to do all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send through an authenticated SMTP or transactional email service, from an address on your own domain&lt;/li&gt;
&lt;li&gt;Publish SPF and DKIM records for that service&lt;/li&gt;
&lt;li&gt;Add a DMARC record, even if it starts at &lt;code&gt;p=none&lt;/code&gt; while you watch the reports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can check what's published in a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT example.com | &lt;span class="nb"&gt;grep &lt;/span&gt;spf
dig +short TXT _dmarc.example.com
&lt;span class="c"&gt;# DKIM needs the selector your email provider gave you:&lt;/span&gt;
dig +short TXT selector1._domainkey.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then send a test submission to a Gmail address and use "Show original". You want to see &lt;code&gt;PASS&lt;/code&gt; next to SPF, DKIM and DMARC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Save the lead before you send the email
&lt;/h2&gt;

&lt;p&gt;Treat the email as a notification. It shouldn't be the only place a lead lives. If SMTP is down for an hour, or a credential expires, the lead should still exist somewhere.&lt;/p&gt;

&lt;p&gt;Our rule: write the submission to storage first, then notify. Here's the shape of it in a Next.js route handler, with a honeypot field for basic bot filtering:&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;// app/api/lead/route.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodemailer&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;saveLead&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="s2"&gt;@/lib/leads&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// your DB, sheet or CRM call&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;host&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;SMTP_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&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;SMTP_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pass&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;SMTP_PASS&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&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="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="nx"&gt;req&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="c1"&gt;// Honeypot: a hidden "company_website" field real users never fill in&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company_website&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;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="na"&gt;ok&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="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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;phone&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;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="na"&gt;ok&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="na"&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;Name and phone are required&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="na"&gt;status&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lead&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;saveLead&lt;/span&gt;&lt;span class="p"&gt;({&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="na"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&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="nf"&gt;toISOString&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"Website enquiry" &amp;lt;leads@example.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&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;LEADS_INBOX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;replyTo&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;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`New enquiry from &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&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;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="s2"&gt;\n&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;phone&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&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;email&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="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&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;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&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="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;err&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;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;Lead saved but email 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;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;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// alert on this&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;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="na"&gt;ok&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;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;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;The honeypot returns a fake success so bots don't learn anything. Validation runs on the server, because anyone can skip client-side checks. And an email failure is logged loudly instead of being swallowed, since the lead is already safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Fire the analytics event only on a real success
&lt;/h2&gt;

&lt;p&gt;A lot of sites send their "lead" event when the button is clicked. That counts double-clicks, validation errors and bot hits as leads, and the numbers in analytics drift away from the numbers in the inbox.&lt;/p&gt;

&lt;p&gt;GA4 has a recommended event for this, &lt;code&gt;generate_lead&lt;/code&gt;. We send it only after the server says the lead was saved:&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;res&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/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;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;headers&lt;/span&gt;&lt;span class="p"&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;Content-Type&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;application/json&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;formData&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;body&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;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="k"&gt;if &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;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;ok&lt;/span&gt;&lt;span class="p"&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="nx"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&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;generate_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;span class="na"&gt;form_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact_main&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;showThankYou&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="nf"&gt;showError&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;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Please call or WhatsApp us.&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;Two more things that bite people. If you use Google Tag Manager, check the data layer variable names match exactly, because a typo there sends events with empty parameters and no error. We had this on a site we run, and the fix was a one-line rename in GTM. And remember to mark the event as a key event in GA4, or it never shows up as a lead in reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test it like code, then keep testing it
&lt;/h2&gt;

&lt;p&gt;Testing once at launch won't cover you. Forms break later, when a plugin updates, a caching layer starts serving a stale nonce, a CAPTCHA key is tied to the old domain after a move, or someone changes the inbox.&lt;/p&gt;

&lt;p&gt;We use a Playwright test that submits the real form and checks the response, not just the thank-you text:&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;// tests/lead-form.spec.js&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;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact form saves a lead&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;page&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[name="name"]&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;Form Test (automated)&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[name="phone"]&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;0000000000&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textarea[name="message"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`monitor &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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="kd"&gt;const&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="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="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&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/lead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&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="p"&gt;]);&lt;/span&gt;

  &lt;span class="nf"&gt;expect&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="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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;expect&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;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="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/thank/i&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&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 it in CI on every deploy, and on a schedule against production (daily is plenty for most sites). Tag the test leads clearly so the sales team can ignore them and filter them out of analytics. If you can, add one more check that the test lead actually reached the inbox or CRM, because that's the part the browser test can't see.&lt;/p&gt;

&lt;p&gt;Once a month, a human should still do one test on a real phone: fill in the form, tap the phone link, tap WhatsApp. Automated tests catch regressions. A person catches "this form asks for eleven fields on mobile and nobody finishes it".&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;List every lead path and who owns it&lt;/li&gt;
&lt;li&gt;Send from your own domain with SPF, DKIM and DMARC&lt;/li&gt;
&lt;li&gt;Save first, notify second, and alert when the notification fails&lt;/li&gt;
&lt;li&gt;Fire &lt;code&gt;generate_lead&lt;/code&gt; only on a confirmed save&lt;/li&gt;
&lt;li&gt;Test the form in CI and on a schedule, and do a real-phone check monthly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the non-code side of this (offer, proof, tracking and the thank-you step), our checklist on &lt;a href="https://withbrightcode.com/insights/conversion-ready-landing-page-before-ads" rel="noopener noreferrer"&gt;what a conversion-ready landing page needs before you turn on ads&lt;/a&gt; covers what marketing and sales should sign off. If the forms live on WordPress, plugin updates are the usual reason they break, which is why &lt;a href="https://withbrightcode.com/insights/wordpress-security-maintenance-basics" rel="noopener noreferrer"&gt;regular security and maintenance&lt;/a&gt; matters for leads, not just for safety.&lt;/p&gt;

&lt;p&gt;How do you monitor forms on sites you've handed over? We'd like to hear what's worked for you in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the team at Brightcode, a Delhi NCR agency that builds &lt;a href="https://withbrightcode.com/services/website-development" rel="noopener noreferrer"&gt;business websites&lt;/a&gt; and keeps them working with &lt;a href="https://withbrightcode.com/services/website-maintenance" rel="noopener noreferrer"&gt;website maintenance&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>testing</category>
      <category>email</category>
    </item>
    <item>
      <title>Redirect maps that survive a rebuild: how we keep SEO when a site changes platform or domain</title>
      <dc:creator>Brightcode</dc:creator>
      <pubDate>Mon, 26 Oct 2026 09:15:36 +0000</pubDate>
      <link>https://dev.to/brightcode/redirect-maps-that-survive-a-rebuild-how-we-keep-seo-when-a-site-changes-platform-or-domain-oli</link>
      <guid>https://dev.to/brightcode/redirect-maps-that-survive-a-rebuild-how-we-keep-seo-when-a-site-changes-platform-or-domain-oli</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Brightcode team, a small web agency in Delhi NCR. Reviewed by Gaurav Khokkar, Founder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most rebuilds we see go wrong in the same boring place. The new site looks great, the Lighthouse scores are green, and three weeks later someone asks why enquiries from Google have dried up. Usually the old URLs are returning 404s, or they redirect to the homepage, or they bounce through three hops before landing anywhere.&lt;/p&gt;

&lt;p&gt;The fix is a redirect map. Everyone knows that. What's less obvious is how to build one that's actually complete, and how to prove it works before DNS flips.&lt;/p&gt;

&lt;p&gt;This is the process we follow when we redesign a site, move it to a new platform, or move it to a new domain. We went through it for our own old domain recently, so a few of these lessons are fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Collect old URLs from more than one source
&lt;/h2&gt;

&lt;p&gt;The live sitemap is the first place people look, and it's usually incomplete. Old blog posts drop out of it, plugin pages never made it in, and campaign landing pages were never meant to be in it.&lt;/p&gt;

&lt;p&gt;We pull URLs from at least these sources and merge them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The current XML sitemap (and any sitemap index children)&lt;/li&gt;
&lt;li&gt;A full crawl of the live site, with a desktop crawler or a small script&lt;/li&gt;
&lt;li&gt;The Pages report in Google Search Console, exported for the longest date range available&lt;/li&gt;
&lt;li&gt;Landing pages from analytics, so you catch URLs that get traffic but aren't linked anywhere&lt;/li&gt;
&lt;li&gt;Pages with external backlinks, from whatever backlink tool you have access to&lt;/li&gt;
&lt;li&gt;Ad and email campaign URLs, which marketing teams keep in spreadsheets you'll have to ask for&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then normalise everything before you deduplicate. Lowercase the host, pick one trailing slash convention, strip tracking parameters like &lt;code&gt;utm_source&lt;/code&gt;, and decide what to do with &lt;code&gt;www&lt;/code&gt; and &lt;code&gt;http&lt;/code&gt;. Without this step you end up with five rows for one page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Decide a fate for every URL
&lt;/h2&gt;

&lt;p&gt;Every old URL gets one of three outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redirect&lt;/strong&gt; to the closest equivalent page on the new site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep&lt;/strong&gt; the same path, because the page still exists with the same purpose&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retire&lt;/strong&gt; with a 410 or a plain 404, because there's genuinely nothing equivalent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tempting shortcut is to send everything you can't match to the homepage. Don't. Google tends to treat mass redirects to an unrelated page as soft 404s, and users who clicked a link about one specific thing land somewhere useless. An honest 404 page with good navigation is better than a misleading redirect.&lt;/p&gt;

&lt;p&gt;"Closest equivalent" also means intent, not just title. Say an old page explained pricing and the new site has no pricing page. The services page that answers the same question is a better match than a blog post with a similar keyword in the slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Keep the map in a boring format
&lt;/h2&gt;

&lt;p&gt;Our redirect map is a CSV with four columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;old&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;notes&lt;/span&gt;
&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;services&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;web&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;design&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="k"&gt;services&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;website&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;development&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;renamed&lt;/span&gt; &lt;span class="k"&gt;service&lt;/span&gt;
&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;blog&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;old&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;post&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;slug&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="k"&gt;insights&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;post&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;merged&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="k"&gt;post&lt;/span&gt;
&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;summer&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;offer&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,,&lt;/span&gt;&lt;span class="mf"&gt;410&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;campaign&lt;/span&gt; &lt;span class="k"&gt;ended&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A spreadsheet is fine for review with the client. But the source of truth should be a file in the repo, because then it can be tested in CI and the server config can be generated from it. Hand-typing hundreds of rules into a hosting panel is how typos ship.&lt;/p&gt;

&lt;p&gt;A few rules we apply while filling it in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No chains.&lt;/strong&gt; If page A already redirects to B, and B now moves to C, update A to point straight at C. Old redirects from previous redesigns are the usual culprit here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point to final URLs.&lt;/strong&gt; The target should return 200 directly, with the trailing slash and protocol your new site actually uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanent means 301 or 308.&lt;/strong&gt; Use 302 or 307 only for something genuinely temporary, like a maintenance page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Put redirects in the right layer
&lt;/h2&gt;

&lt;p&gt;Where the rules live matters for speed and for who can maintain them later.&lt;/p&gt;

&lt;p&gt;For a handful of pattern rules on a Next.js site, &lt;code&gt;redirects()&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt; works well. Setting &lt;code&gt;permanent: true&lt;/code&gt; returns a 308, which search engines treat the same as a 301.&lt;/p&gt;

&lt;p&gt;For a long list of one-to-one rules, we prefer the edge or the web server. An Nginx &lt;code&gt;map&lt;/code&gt; block generated from the CSV keeps lookups fast, and so do CDN-level bulk redirect lists. Both run before your app does any work, which keeps redirect latency low and means a broken app deploy won't take your redirects down with it.&lt;/p&gt;

&lt;p&gt;On WordPress, a redirect plugin is fine for small sites, but for a big replatform we still prefer server rules generated from the same CSV. On Shopify, URL redirects can be imported in bulk from the admin with a CSV, which suits the map format above.&lt;/p&gt;

&lt;p&gt;Whatever layer you choose, keep one place. Redirects split between a plugin, &lt;code&gt;.htaccess&lt;/code&gt;, the CDN and the app are very hard to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test the map like code
&lt;/h2&gt;

&lt;p&gt;This is the step that gets skipped, and it's the cheapest one. We run a small script against staging, and again against production right after launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# usage: ./check-redirects.sh redirects.csv https://staging.example.com&lt;/span&gt;
&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;, &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; old new status notes&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;first&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base$old&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; code url hops &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;--max-redirs&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{url_effective} %{num_redirects}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base$old&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; /&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base$new&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# full URLs for domain moves&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="s2"&gt;: got &lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;, expected &lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hops&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; 200 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="s2"&gt;: ended at &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;) after &lt;/span&gt;&lt;span class="nv"&gt;$hops&lt;/span&gt;&lt;span class="s2"&gt; hops"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each row we check three things: the first response has the expected status, the final URL matches &lt;code&gt;new_url&lt;/code&gt;, and it gets there in one hop with a 200 at the end. Anything else gets fixed before launch, not after.&lt;/p&gt;

&lt;p&gt;It's also worth crawling the new site itself for internal links that still point to old paths. A redirect will catch them, but every internal link should go straight to the final URL. Menus, footers and links inside old blog content are where these hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Update everything that points at URLs
&lt;/h2&gt;

&lt;p&gt;Redirects handle people and bots arriving from outside. Inside the new site, a few more things need to agree with the map:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Canonical tags point to the new URLs, never to redirected ones&lt;/li&gt;
&lt;li&gt;The XML sitemap lists only final, indexable URLs that return 200&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hreflang&lt;/code&gt;, structured data and Open Graph URLs use the new paths&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; on staging blocks crawling, and the production one doesn't (check this on launch day, every time)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a domain move, there's one more step. Verify both domains in Google Search Console, make sure the old homepage 301s to the new one, and then use the Change of Address tool. Google's own guidance is to keep redirects in place for at least a year, so don't let the old domain lapse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Watch it for a few weeks
&lt;/h2&gt;

&lt;p&gt;After launch we check the Search Console Pages report and crawl stats regularly, and look for new 404s in server logs. Old URLs you missed will show up there, often from backlinks nobody knew about. Add them to the CSV, regenerate the rules, re-run the test.&lt;/p&gt;

&lt;p&gt;Rankings often wobble for a bit after a migration, even when everything is done right. What you want to avoid is the drop that never recovers because a whole section of URLs is quietly broken.&lt;/p&gt;

&lt;p&gt;If you're still deciding whether a site needs a full rebuild at all, we wrote about &lt;a href="https://withbrightcode.com/insights/when-to-rebuild-vs-redesign-website" rel="noopener noreferrer"&gt;when to rebuild versus redesign&lt;/a&gt;. And for the non-developer side of launch planning, like owners, access and tracking, our &lt;a href="https://withbrightcode.com/insights/website-migration-checklist" rel="noopener noreferrer"&gt;website migration checklist&lt;/a&gt; covers the rest.&lt;/p&gt;

&lt;p&gt;What's the worst redirect mess you've inherited from a previous redesign? Tell us in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the team at Brightcode, a Delhi NCR agency that builds &lt;a href="https://withbrightcode.com/services/website-development" rel="noopener noreferrer"&gt;websites&lt;/a&gt; and handles &lt;a href="https://withbrightcode.com/services/platform-migration" rel="noopener noreferrer"&gt;SEO-safe platform migrations&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>devops</category>
      <category>migration</category>
    </item>
    <item>
      <title>Website, web app or SaaS? The architecture questions we ask before writing a line of code</title>
      <dc:creator>Brightcode</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:21:16 +0000</pubDate>
      <link>https://dev.to/brightcode/website-web-app-or-saas-the-architecture-questions-we-ask-before-writing-a-line-of-code-4fm</link>
      <guid>https://dev.to/brightcode/website-web-app-or-saas-the-architecture-questions-we-ask-before-writing-a-line-of-code-4fm</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Brightcode team, a small web agency in Delhi NCR. Reviewed by Gaurav Khokkar, Founder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A lot of project briefs we get start with "we need an app". Sometimes that's true. Often what the person needs is a fast website with a good form, plus one paid tool they haven't heard of yet. And now and then a "simple website" turns out to be a multi-tenant product with billing.&lt;/p&gt;

&lt;p&gt;Calling it wrong is expensive in both directions. Build a SaaS when the client needed a brochure site and you've burned months. Build a brochure site when they needed a portal and you'll rebuild it within a year.&lt;/p&gt;

&lt;p&gt;So before we pick a stack or write an estimate, we sort the project into one of three buckets. Here's how, and what changes in the architecture once we know.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line test
&lt;/h2&gt;

&lt;p&gt;Ask: &lt;strong&gt;will people come here to read, or to do work?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mostly reading (services, products, contact details, then call, WhatsApp or buy) → &lt;strong&gt;website&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Logging in to get something done (book, order, approve, check a report) → &lt;strong&gt;web app&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The same web app, sold to many customers who each get their own walled-off space → &lt;strong&gt;SaaS&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A store sits in between. It's a website with an app-like checkout. A service site with a client login area is a website with a small web app attached. That's fine. Name the parts separately and scope them separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 1: Does anyone need to log in?
&lt;/h2&gt;

&lt;p&gt;This is the biggest fork. The moment there's a login, you're on the hook for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auth (sessions or tokens, password reset, maybe OTP or Google sign-in)&lt;/li&gt;
&lt;li&gt;Roles and permissions, even if it's only "admin" and "user" on day one&lt;/li&gt;
&lt;li&gt;Data that differs per person, which changes how you cache everything&lt;/li&gt;
&lt;li&gt;Security reviews you didn't need for a marketing site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If nobody logs in, keep it a website. Static or mostly static pages, a CMS for the people who edit content, aggressive caching at the CDN. If only the client's team logs in to edit content, that's a CMS, not a web app. Don't build custom auth for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: Is the content the same for everyone?
&lt;/h2&gt;

&lt;p&gt;Websites serve the same page to every visitor. That's what makes them cheap to host and fast to load. You can pre-render, cache at the edge for minutes or hours, and serve most traffic without touching your server.&lt;/p&gt;

&lt;p&gt;Web apps serve different data to each user, so pages are rendered per request or built on the client from an API. Caching moves down to the data layer. This is why "can we just add a dashboard to the website?" is rarely a small change. It brings a second rendering model into the same codebase.&lt;/p&gt;

&lt;p&gt;A pattern that works well for us: keep the marketing site and the app as two deployables. &lt;code&gt;example.com&lt;/code&gt; is the website, built for SEO and speed. &lt;code&gt;app.example.com&lt;/code&gt; is the web app, built for logged-in users. They share a design system, not a runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: Is there more than one customer organisation?
&lt;/h2&gt;

&lt;p&gt;This is the question that separates a web app from SaaS. A portal for one distributor and its dealers is a web app, even if hundreds of dealers log in. A tool you sell to fifty distributors, each with their own dealers, is SaaS.&lt;/p&gt;

&lt;p&gt;SaaS adds a few things you can't bolt on cheaply later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tenancy.&lt;/strong&gt; Usually a &lt;code&gt;tenant_id&lt;/code&gt; on every row with strict scoping in queries (or row-level security in Postgres). Separate schemas or databases per tenant are possible, but they make migrations and reporting harder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing.&lt;/strong&gt; Plans, trials, upgrades, failed payments, invoices with GST if you sell in India. Subscription billing through a gateway like Razorpay or Stripe is a project in its own right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding and admin.&lt;/strong&gt; Sign-up, inviting teammates, a super-admin view for your own support team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the client isn't planning to sell the software to other companies, don't build tenancy "just in case". It's the most common over-engineering we see in early briefs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 4: Does a ready-made tool already do 80% of it?
&lt;/h2&gt;

&lt;p&gt;Before custom anything, we check whether existing SaaS covers the job. CRMs, booking tools, invoicing, helpdesks. If it does, the right build is usually a good website in front of the tool, plus a thin integration layer: the website form posts to the CRM's API, a webhook sends a WhatsApp alert, a small script updates a sheet.&lt;/p&gt;

&lt;p&gt;Custom earns its place when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The team spends hours a day copying data between tools&lt;/li&gt;
&lt;li&gt;A missing feature in the current tool causes real mistakes&lt;/li&gt;
&lt;li&gt;Per-seat pricing keeps climbing as the team grows&lt;/li&gt;
&lt;li&gt;The process itself is the business's edge (a particular quoting or dealer pricing system, say)&lt;/li&gt;
&lt;li&gt;The software is the product&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of our &lt;a href="https://withbrightcode.com/services/software-api-development" rel="noopener noreferrer"&gt;software and API work&lt;/a&gt; lives in that thin layer, not in replacing tools that already work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 5: Who looks after it in year two?
&lt;/h2&gt;

&lt;p&gt;This one decides more than the stack.&lt;/p&gt;

&lt;p&gt;A website mostly needs content edits, dependency or plugin updates and someone watching uptime. A web app needs all that plus bug fixes, database care, auth patches and small feature requests that never stop. A SaaS adds on-call for paying customers.&lt;/p&gt;

&lt;p&gt;So we ask who will own it after launch, and scope that up front. If the honest answer is "nobody technical", that pushes us towards simpler choices: a hosted CMS, fewer custom parts, managed infrastructure. If you're weighing a headless setup for a content-heavy site, we wrote up &lt;a href="https://withbrightcode.com/insights/headless-cms-when-it-makes-sense" rel="noopener noreferrer"&gt;when a headless CMS makes sense&lt;/a&gt; and when it's overkill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in the estimate
&lt;/h2&gt;

&lt;p&gt;Once the bucket is clear, the estimate changes shape:&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;Website&lt;/th&gt;
&lt;th&gt;Web app&lt;/th&gt;
&lt;th&gt;SaaS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main unit we price&lt;/td&gt;
&lt;td&gt;Unique templates and content&lt;/td&gt;
&lt;td&gt;Screens, roles and workflows&lt;/td&gt;
&lt;td&gt;Everything in web app, plus tenancy, billing and onboarding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rendering&lt;/td&gt;
&lt;td&gt;Pre-rendered or cached&lt;/td&gt;
&lt;td&gt;Per request or client-side&lt;/td&gt;
&lt;td&gt;Per request or client-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;None (CMS login only)&lt;/td&gt;
&lt;td&gt;Yes, with roles&lt;/td&gt;
&lt;td&gt;Yes, with roles per tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing focus&lt;/td&gt;
&lt;td&gt;Layout, speed, forms, SEO&lt;/td&gt;
&lt;td&gt;Permissions, edge cases, data&lt;/td&gt;
&lt;td&gt;All of that, plus tenant isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Year-two work&lt;/td&gt;
&lt;td&gt;Updates, edits, uptime&lt;/td&gt;
&lt;td&gt;Fixes, features, data care&lt;/td&gt;
&lt;td&gt;Same, plus support and billing issues&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We also split mixed briefs into two lines: "marketing website" and "client portal", each with its own estimate. Clients can see where the money goes, and they can launch the website first while the app is still being built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read or do? That's website vs web app.&lt;/li&gt;
&lt;li&gt;One customer organisation or many? That's web app vs SaaS.&lt;/li&gt;
&lt;li&gt;Can a ready-made tool do most of it? Then build the website and the glue, not the tool.&lt;/li&gt;
&lt;li&gt;Who maintains it? Let that answer simplify your choices.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We wrote a longer, non-technical version of this for business owners: &lt;a href="https://withbrightcode.com/insights/website-vs-web-app-vs-saas" rel="noopener noreferrer"&gt;website vs web app vs SaaS: what does your business actually need?&lt;/a&gt;. It's handy to send to a client before the first call.&lt;/p&gt;

&lt;p&gt;How do you sort briefs like this? Do you split the marketing site and the app from day one, or keep them in one codebase? Tell us in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the team at Brightcode, a Delhi NCR agency that builds &lt;a href="https://withbrightcode.com/services/website-development" rel="noopener noreferrer"&gt;websites&lt;/a&gt;, e-commerce stores and &lt;a href="https://withbrightcode.com/services/custom-web-app-development" rel="noopener noreferrer"&gt;custom web apps&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>How we turn "how much for a website?" into a quote you can defend: a developer's scoping checklist</title>
      <dc:creator>Brightcode</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:12:15 +0000</pubDate>
      <link>https://dev.to/brightcode/how-we-turn-how-much-for-a-website-into-a-quote-you-can-defend-a-developers-scoping-checklist-1pd2</link>
      <guid>https://dev.to/brightcode/how-we-turn-how-much-for-a-website-into-a-quote-you-can-defend-a-developers-scoping-checklist-1pd2</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Brightcode team, a small web agency in Delhi NCR. Reviewed by Gaurav Khokkar, Founder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you build websites for small businesses, you know this message. "How much for a website?" And the person wants a number in the next reply.&lt;/p&gt;

&lt;p&gt;We're a small web team in Delhi and that question reaches us every week. Answering with a number straight away feels helpful. It rarely is. Either the client compares it with a quote that covers half the work, or three weeks in you learn that "simple website" meant online booking plus a CRM sync.&lt;/p&gt;

&lt;p&gt;So we stopped quoting off the first message. Below is the checklist we now use to get from a vague ask to a scope we can price and stand behind. It's written for developers and freelancers. If you're the one buying a site, it shows what a good quote should rest on.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Count templates, not pages
&lt;/h2&gt;

&lt;p&gt;"10 pages" tells you very little. Ten blog posts share one template. A home page, a service page, a product page and a checkout are four separate builds. Each has its own layout, states and edge cases.&lt;/p&gt;

&lt;p&gt;So we list every &lt;strong&gt;unique template&lt;/strong&gt; and price those. Pages that reuse a template are content entry, and the quote says so. That single change explains most of the gap between a ₹15,000 quote and a ₹1.5 lakh one, when both sides are calling it "a 10-page site".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Write down the project type before anything else
&lt;/h2&gt;

&lt;p&gt;Clients use the same words for very different jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Brochure site&lt;/strong&gt;: mostly read-only content and a contact form&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lead-gen site&lt;/strong&gt;: landing pages, forms, conversion tracking, ad-ready structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store&lt;/strong&gt;: catalogue, cart, checkout, payments, shipping, returns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web app&lt;/strong&gt;: logins, roles, dashboards, reports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaaS&lt;/strong&gt;: one codebase serving many customers, billing, multi-tenancy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The further down that list, the more testing and long-term maintenance you're signing up for. When a brief straddles two types, like a service site with a client portal, we split the quote in two. That way the expensive part is out in the open.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Treat every integration as its own mini-project
&lt;/h2&gt;

&lt;p&gt;Here in India the integrations list tends to look the same: a payment gateway (Razorpay, PayU, Cashfree), WhatsApp alerts, a CRM, Google Sheets. Sometimes accounting software like Tally, or an ERP.&lt;/p&gt;

&lt;p&gt;Every one of those means setup, auth, error handling and a test plan. Plus the webhook that fails at 2 a.m. A gateway isn't finished when the happy path works. It's finished when refunds, failed payments and duplicate webhooks are handled too. Older systems with thin API docs take longer, and we flag that as a risk in the estimate instead of hiding the hours.&lt;/p&gt;

&lt;p&gt;When a client asks "can you just connect it to X?", our answer is "yes, and here's what that involves". A lot of &lt;a href="https://withbrightcode.com/services/software-api-development" rel="noopener noreferrer"&gt;software and API integration work&lt;/a&gt; hides inside projects that get called "a website".&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Decide the platform by who edits the site after launch
&lt;/h2&gt;

&lt;p&gt;WordPress, Shopify, Next.js, custom. We try not to start from what we enjoy writing. We start from who will update the site, how often, and what it'll cost to run over the next two or three years.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WordPress&lt;/strong&gt; suits content-heavy sites where non-developers publish often. Someone still has to own updates and security.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify&lt;/strong&gt; gets a store live fast. The monthly plan and paid apps are ongoing costs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js or custom&lt;/strong&gt; gives you speed and control, for more development effort up front.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a longer write-up of the trade-offs in &lt;a href="https://withbrightcode.com/insights/wordpress-vs-custom-website" rel="noopener noreferrer"&gt;WordPress vs a custom website&lt;/a&gt;. Short version: picking a platform also picks the running costs, so bring it into the quote conversation early.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ask who supplies content and photos
&lt;/h2&gt;

&lt;p&gt;Cheap quotes almost always assume finished copy and photos show up on day one. They rarely do. In our experience content is the most common reason a project runs late. So we ask straight out: who writes the copy, who shoots the product photos, does anything need translating? If the answer is "you", it becomes a separate line item with its own timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Put migration and SEO in writing
&lt;/h2&gt;

&lt;p&gt;If the client already has a site that ranks, a redesign without a redirect map can quietly wipe out years of traffic. We list migration as its own line: URL inventory, redirect map, and a Search Console check after launch. Basic on-page SEO (titles, meta, headings, sitemap) comes with every build we ship. Anything deeper gets scoped separately. Our &lt;a href="https://withbrightcode.com/insights/website-migration-checklist" rel="noopener noreferrer"&gt;website migration checklist&lt;/a&gt; is the list we actually work from.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Show the first-year cost, not just the build cost
&lt;/h2&gt;

&lt;p&gt;If the quote only shows the build price, month four brings a nasty surprise. So we add a short running-costs section: domain and SSL, hosting, yearly theme or plugin licences, platform plans like Shopify, gateway fees, SMS/email/WhatsApp API charges, and maintenance.&lt;/p&gt;

&lt;p&gt;People rarely complain about running costs they were told about upfront. It's the ones nobody mentioned that cause the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Agree on what "done" means
&lt;/h2&gt;

&lt;p&gt;Our scope documents end with the dull bits. They're also the bits that prevent fights later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many revision rounds&lt;/li&gt;
&lt;li&gt;Who owns the domain, hosting account and code (always the client)&lt;/li&gt;
&lt;li&gt;What's covered after launch, and for how long&lt;/li&gt;
&lt;li&gt;What counts as a change request&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The red flags we see in other quotes
&lt;/h2&gt;

&lt;p&gt;Clients sometimes show us a competing quote. These are the things that make us wince, and they're worth checking in your own quotes too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One line: "Website – ₹X", no scope&lt;/li&gt;
&lt;li&gt;Nothing about revisions, hosting or maintenance&lt;/li&gt;
&lt;li&gt;Domain, hosting or code registered in the agency's name&lt;/li&gt;
&lt;li&gt;Mobile layout, speed or basic SEO sold as extras&lt;/li&gt;
&lt;li&gt;No redirect plan when replacing a live site&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;None of this is hard. It just means having one proper conversation before the number instead of after it. The quote takes a little longer to write. In exchange, it survives being compared line by line with someone else's.&lt;/p&gt;

&lt;p&gt;On the buying side and want to see how all this turns into real price ranges in our market? We published a longer guide: &lt;a href="https://withbrightcode.com/insights/website-development-cost-delhi-ncr" rel="noopener noreferrer"&gt;what decides website development cost in Delhi NCR&lt;/a&gt;. It has the ranges other agencies publish for each type of site, the running costs, and a prep list for getting quotes you can compare.&lt;/p&gt;

&lt;p&gt;What's on your scoping checklist that isn't on ours? Tell us in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the team at Brightcode, a Delhi NCR agency that does &lt;a href="https://withbrightcode.com/services/website-development" rel="noopener noreferrer"&gt;website development&lt;/a&gt;, e-commerce and web apps.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>wordpress</category>
      <category>freelancing</category>
      <category>career</category>
    </item>
  </channel>
</rss>
