Skip to content
SoyGarfield
Research #04

How I built a law firm site with 37 landing pages (and three tries to get email right)

How do you build a high-conversion site for a law firm when the first, second and even third attempt at something as simple as a contact form doesn't work as planned?

By Pietro Fiorillo ·

In this article
Timeline with three attempts to send the contact form. Attempt 1: Cloud Function plus Nodemailer, built but never called from the site. Attempt 2: API route plus Resend, can't run on a static export. Attempt 3, in production: client-side EmailJS.
Two abandoned attempts and one in production. All three are still visible in the code and the git history. Original diagram.

The problem: generating inquiries for a firm that depended only on referrals

The firm had real experience handling compensation claims for traffic accidents and insurance losses, but depended entirely on word-of-mouth referrals. With no digital presence and no active lead generation, growth had a low ceiling.

Why 37 landing pages instead of a single services page

The site has 37 route folders, mostly landing pages by accident type: collisions, pedestrian hits, hit and run, commuting work accidents and 19 different compensation types (amputation, paraplegia, quadriplegia, whiplash, among others). Each one competes for a specific search instead of all of them competing for the same generic terms.

On top of that there's a 6-post blog across 4 categories (Guides, Legislation, Injuries, Accident Types) to capture traffic from people who don't yet know they have a case.

The three attempts at something as simple as a contact form

This is the part that usually doesn't make it into a success story, and it's the one that best explains how software actually gets built. The contact form went through three different implementations before landing on the one that works today.

The first attempt was a Cloud Function that received the form and sent the email with Nodemailer, also saving the lead to a Firestore collection. It works as an isolated function, but it's never called from the frontend in production: it was built and left disconnected.

const db = admin.firestore();
const docRef = await db.collection("leads").add({
  nombre, email,
  telefono: telefono || "",
  asunto: asunto || "",
  mensaje,
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
  ip: req.ip || "",
  userAgent: req.get("user-agent") || "",
});
Real (trimmed) code from the first Cloud Function, in functions/index.js. Still in the repository, but nothing on the site calls it.

The second attempt was a Next.js API route (/api/send-email) using Resend, with full bilingual HTML templates. Technically cleaner than the first, but with a core problem: it's a dynamic route, and Next.js doesn't include routes that depend on the request when the site is exported as static. The build marks it as a server route (ƒ), and that mark is, in practice, a death sentence for a site on Firebase Hosting.

The third attempt, the one in production, sends the form directly from the browser with EmailJS, in two calls to two different templates.

await emailjs.send(SERVICE_ID, 'template_9mu4p5c', {
  nombre, email, telefono, asunto, mensaje,
  cc_email: 'lexxionaabogados@gmail.com',
}, PUBLIC_KEY);
await emailjs.send(SERVICE_ID, 'template_zvnj7ov', {
  nombre, email, telefono, asunto, mensaje,
}, PUBLIC_KEY);
window.location.href = '/gracias';
Real code from src/app/contacto/page.js. This is what runs in production today.

None of the three decisions were arbitrary at the time. Each one solved something the previous one didn't solve well, until landing on one that runs reliably within the real constraints of static export.

How I validated this architecture

I ran npm run build against the real repository (Next.js 16.1.6, Turbopack) and confirmed the result instead of describing it from memory.

const nextConfig = {
  output: 'export',
  trailingSlash: true,
  images: { unoptimized: true },
};
Real code from next.config.mjs.

Measurable results

Real metrics measured on October 18, 2026.
MetricResultEnvironment / evidence
Lighthouse Performance (mobile, lab)71 / 100Lighthouse 13.5.0, simulated throttling
LCP (lab)6.9 sSame Lighthouse run, not field (CrUX) data
Local build time7.26 snext build with Turbopack, Next.js 16.1.6
Static export size11 MB across 720 files/out folder generated by that build
Routes generated79 (77 static, 2 SSG)Build log. The dynamic Resend route isn't included
Development commits91, from July to October 2026The repository's git log

SEO and GEO: what the robots.txt says, and what it actually controls

The site's real robots.txt explicitly allows GPTBot, ClaudeBot, PerplexityBot, Google-Extended and others, and blocks aggressive SEO scrapers. The file itself labels Google-Extended as «Google Gemini & AI Overviews», but according to Google's own documentation that token doesn't control inclusion in Google Search or in AI Overviews: it only decides whether content can be used to train models and ground answers. That association is worth not repeating as if it were accurate.

The right architecture isn't the one designed well on paper, it's the one that keeps working after the second failed attempt.

Pietro Fiorillo

My part in this project

My part in Lexiona Abogados included the full web development, the Google and Meta Ads campaigns, and the conversion tracking integration, as part of my work at MANYA Digital.

What I'd do differently: dead code that shouldn't stay dead

The Cloud Function with Nodemailer and the Resend route don't break anything today because nothing calls them. But they're still there, like two paths someone could mistakenly take thinking they're active. There's no note anywhere in the repository saying which of the three mechanisms is the real one.

I also didn't verify whether the production form has any spam protection. EmailJS doesn't solve that on its own, and it's the kind of question worth answering before it becomes a problem.

What I'd test next

I'd document in the repository itself which of the three submission mechanisms is active, so no one wastes time on the other two, and I'd evaluate whether it's worth deleting the dead code outright instead of leaving it as a confusing possibility. I'd also check whether the form needs rate limiting or a captcha, something I didn't get to verify in this entry.

Sources

Frequently asked questions

Why did the site end up sending the form with EmailJS instead of a custom backend?

Because the site is a Next.js static export and can't run server routes in production. Before landing on EmailJS I tried a Cloud Function with Nodemailer and an API route with Resend; neither made it to production as originally designed.

What happened to the Cloud Function and the Resend route?

Both are still in the repository, but unused. The Cloud Function writes leads to Firestore and is never called from the site. The Resend route is a dynamic API route, and Next.js doesn't include it in a static export: it can't run in production as it stands.

Is it safe to send the email directly from the browser with EmailJS?

EmailJS's public key is meant to be exposed on the client, like a public API key. The real risk is form abuse (spam), not credential leakage. I didn't verify whether the project has rate limiting or a captcha in this entry.

How does the firm measure whether campaigns work, with no CRM?

With data from the ad platforms themselves: Meta's Conversions API receives conversion events from a Cloud Function, and PostHog tracks on-site behavior. Neither replaces a CRM with case tracking.

Why 37 landing pages instead of a single services page?

Because each type of accident (collision, pedestrian hit, hit and run, 19 compensation types, among others) is a different search with different intent. One generic page competes worse for each specific term than a dedicated landing page.

How much does it cost to maintain a site like this?

Hosting is minimal since it's a static export on Firebase Hosting. The real cost is in the Google and Meta Ads campaigns, and the time spent keeping content (37 landing pages, 6 blog posts) current.