Browse documentation

Specifications

RankFlow AI — Landing Page Hosting & DNS Documentation

│ Landing Page System │

docs/specs/landing-page-dns.md
On this page

Version: 1.0.0
Route: src/app/site/[practiceSlug]/page.tsx
Hosting: Subdomain (*.rankflow.ai) + Custom Domain (CNAME)
Rendering: SSG with ISR (Incremental Static Regeneration)


1. Architecture Overview#

┌─────────────────────────────────────────────────────────────┐
│                    Landing Page System                        │
│                                                              │
│  ┌─────────────┐      ┌─────────────┐      ┌───────────┐   │
│  │   Client    │      │   Cloudflare│      │   Next.js │   │
│  │   Request   │ ───→ │   DNS + CDN │ ───→ │   SSG     │   │
│  │             │      │             │      │   Render  │   │
│  └─────────────┘      └─────────────┘      └───────────┘   │
│                                                      │       │
│                                                      ↓       │
│                                              ┌───────────┐   │
│                                              │  Prisma   │   │
│                                              │  DB       │   │
│                                              └───────────┘   │
└─────────────────────────────────────────────────────────────┘

Key Decisions#

Decision Choice Rationale
Rendering SSG + ISR Fast, SEO-friendly, auto-updates on content change
Hosting Dokploy on AWS EC2 Own the infrastructure, no Vercel limits
DNS Cloudflare API Wildcard SSL, CDN, programmatic management
Subdomains *.rankflow.ai Zero client setup, instant assignment
Custom domains CNAME to our origin Client owns domain, we host

2. Subdomain System#

Wildcard DNS Setup#

*.rankflow.ai → A record → Dokploy EC2 IP
rankflow.ai   → A record → Dokploy EC2 IP

Subdomain Assignment Flow#

1. Client onboarded
2. System generates slug: "dr-smith-dental" (from business name)
3. Subdomain assigned: "dr-smith-dental.rankflow.ai"
4. No DNS action needed (wildcard handles all)
5. Site immediately resolvable

Middleware Routing#

// src/middleware.ts

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  const host = request.headers.get("host") || "";
  
  // Check if it's a custom domain or subdomain
  if (host.endsWith(".rankflow.ai") && host !== "rankflow.ai") {
    const slug = host.replace(".rankflow.ai", "");
    
    // Rewrite to site renderer
    return NextResponse.rewrite(
      new URL(`/site/${slug}${request.nextUrl.pathname}`, request.url)
    );
  }
  
  // Custom domain: lookup practice by domain
  if (!host.includes("rankflow.ai")) {
    const practice = await db.practice.findUnique({
      where: { customDomain: host },
    });
    
    if (practice) {
      return NextResponse.rewrite(
        new URL(`/site/${practice.slug}${request.nextUrl.pathname}`, request.url)
      );
    }
  }
  
  return NextResponse.next();
}

3. Custom Domain System#

DNS Requirements for Client#

Record Type Name Value TTL
CNAME @ (or www) cname.rankflow.ai 300

Domain Verification Flow#

1. Client enters custom domain in dashboard
2. System creates DNS record via Cloudflare API
3. System polls DNS every 5 minutes
4. Once verified → SSL cert provisioned (Cloudflare/ACME)
5. Status updated to "ACTIVE"
6. Site goes live at custom domain

Cloudflare API Integration#

// src/server/services/dns/cloudflare.ts

export async function addCustomDomain(domain: string, practiceId: string) {
  // 1. Add DNS record to Cloudflare
  await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${CF_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "CNAME",
      name: domain,
      content: "cname.rankflow.ai",
      proxied: true, // Enable Cloudflare CDN + SSL
      ttl: 1, // Auto
    }),
  });
  
  // 2. Update practice record
  await db.practice.update({
    where: { id: practiceId },
    data: { 
      customDomain: domain,
      domainStatus: "PENDING_VERIFICATION",
    },
  });
  
  // 3. Start verification polling
  await queues.domainVerify.add("verify", { practiceId, domain }, {
    repeat: { every: 300000 }, // Every 5 minutes
    jobId: `verify-${practiceId}`,
  });
}

export async function verifyDomain(domain: string): Promise<boolean> {
  try {
    const records = await dns.resolveCname(domain);
    return records.includes("cname.rankflow.ai");
  } catch {
    return false;
  }
}

Domain Status States#

Status Meaning Next Action
PENDING Client entered domain Waiting for DNS propagation
VERIFYING DNS check in progress Auto-polling every 5 min
ACTIVE DNS verified, SSL ready Site live
FAILED DNS not pointing to us Notify client with instructions
EXPIRED SSL cert expired Auto-renew or alert

4. Site Templates#

Available Templates#

Template ID Vertical Sections Primary Color
medical-modern Doctor hero, about, services, testimonials, faq, contact, cta, reviews-widget #2563eb
dental-clean Dentist hero, about, services, gallery, testimonials, faq, contact, cta #06b6d4
clinic-premium Clinic hero, stats, about, services, team, testimonials, faq, blog, contact, cta #0f172a
ca-professional CA hero, about, services, testimonials, faq, contact, cta #059669
lawyer-authority Lawyer hero, about, services, testimonials, faq, contact, cta #7c3aed

Template Configuration#

// src/lib/site-templates.ts

export interface SiteTemplate {
  id: string;
  name: string;
  vertical: string;
  sections: string[];
  defaultConfig: {
    primaryColor: string;
    secondaryColor: string;
    fontFamily: string;
    borderRadius: string;
    buttonStyle: string;
  };
}

export const templates: Record<string, SiteTemplate> = {
  "medical-modern": {
    id: "medical-modern",
    name: "Medical Modern",
    vertical: "doctor",
    sections: ["hero", "about", "services", "testimonials", "faq", "contact", "cta", "reviews-widget"],
    defaultConfig: {
      primaryColor: "#2563eb",
      secondaryColor: "#f8fafc",
      fontFamily: "Inter, system-ui, sans-serif",
      borderRadius: "0.5rem",
      buttonStyle: "solid",
    },
  },
  // ... other templates
};

Section Components#

Section Component Purpose
hero HeroSection Title, subtitle, CTA, background image
about AboutSection Doctor/clinic bio, photo
services ServicesSection Service list with descriptions
testimonials TestimonialsSection Patient reviews carousel
faq FAQSection Accordion Q&A with schema markup
contact ContactSection Phone, email, hours, map embed, form
cta CTASection Final call-to-action
reviews-widget ReviewsWidget Live Google reviews feed
stats StatsSection Numbers (patients, years, ratings)
team TeamSection Staff photos and bios
blog BlogSection Recent articles
gallery GallerySection Photo grid

5. Dynamic Site Renderer#

Page Implementation#

// src/app/site/[practiceSlug]/page.tsx

import { notFound } from "next/navigation";
import { Metadata } from "next";
import { db } from "@/server/db";
import { SchemaInjector } from "@/components/site/SchemaInjector";
import { sectionComponents } from "@/components/site/sections";

interface Props {
  params: { practiceSlug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const practice = await getPracticeBySlug(params.practiceSlug);
  if (!practice) return { title: "Not Found" };
  
  const heroSection = practice.siteSections.find(s => s.sectionKey === "hero");
  const title = heroSection?.content?.match(/<h1[^>]*>(.*?)<\/h1>/)?.[1] || practice.name;
  
  return {
    title: `${title} | ${practice.locations[0]?.city || ""}`,
    description: practice.seoDescription || `${practice.name} - Professional healthcare services`,
    robots: practice.sitePublished ? "index, follow" : "noindex, nofollow",
    alternates: { 
      canonical: `https://${practice.customDomain || `${practice.subdomain}.rankflow.ai`}` 
    },
    openGraph: {
      title: practice.name,
      description: practice.seoDescription,
      type: "website",
      images: practice.logoUrl ? [practice.logoUrl] : undefined,
    },
  };
}

export const revalidate = 3600; // ISR: revalidate every hour

export default async function SitePage({ params }: Props) {
  const practice = await getPracticeBySlug(params.practiceSlug);
  if (!practice || !practice.sitePublished) {
    notFound();
  }
  
  const sections = practice.siteSections
    .filter(s => s.isVisible)
    .sort((a, b) => a.sortOrder - b.sortOrder);
  
  const template = templates[practice.siteTemplate] || templates["medical-modern"];
  
  return (
    <div
      style={{
        "--primary": template.defaultConfig.primaryColor,
        "--secondary": template.defaultConfig.secondaryColor,
      } as React.CSSProperties}
      className="min-h-screen"
    >
      <SchemaInjector practice={practice} />
      
      {sections.map((section) => {
        const Component = sectionComponents[section.sectionKey];
        if (!Component) return null;
        
        return (
          <section
            key={section.id}
            id={section.sectionKey}
            className={`section-${section.sectionKey}`}
          >
            <Component
              content={section.content}
              mediaUrls={section.mediaUrls}
              config={section.config}
              practice={practice}
            />
          </section>
        );
      })}
    </div>
  );
}

async function getPracticeBySlug(slug: string) {
  return db.practice.findUnique({
    where: { slug, deletedAt: null },
    include: {
      siteSections: true,
      locations: { where: { isPrimary: true }, take: 1 },
      gbpAccounts: { where: { isActive: true } },
    },
  });
}

6. Schema Injection#

JSON-LD Types by Vertical#

Vertical Schema Types
Doctor Physician, MedicalBusiness, LocalBusiness, FAQPage
Dentist Dentist, MedicalBusiness, LocalBusiness, FAQPage
Clinic MedicalClinic, MedicalBusiness, LocalBusiness, FAQPage
CA ProfessionalService, LocalBusiness, FAQPage
Lawyer LegalService, LocalBusiness, FAQPage

SchemaInjector Component#

// src/components/site/SchemaInjector.tsx

export function SchemaInjector({ practice }: { practice: PracticeWithRelations }) {
  const location = practice.locations[0];
  const faqSection = practice.siteSections.find(s => s.sectionKey === "faq");
  
  const schemas = [];
  
  // 1. LocalBusiness / Physician / Dentist / etc.
  schemas.push({
    "@context": "https://schema.org",
    "@type": getBusinessType(practice.type),
    name: practice.name,
    description: practice.seoDescription,
    url: `https://${practice.customDomain || `${practice.subdomain}.rankflow.ai`}`,
    telephone: location?.phone,
    email: location?.email,
    address: {
      "@type": "PostalAddress",
      streetAddress: location?.address,
      addressLocality: location?.city,
      addressRegion: location?.state,
      postalCode: location?.postalCode,
      addressCountry: location?.country || "IN",
    },
    geo: location?.latitude ? {
      "@type": "GeoCoordinates",
      latitude: location.latitude,
      longitude: location.longitude,
    } : undefined,
    image: practice.logoUrl,
    priceRange: "₹₹",
    openingHoursSpecification: parseBusinessHours(location?.businessHours),
  });
  
  // 2. FAQPage (if FAQ section exists)
  if (faqSection) {
    const faqs = parseFAQContent(faqSection.content);
    schemas.push({
      "@context": "https://schema.org",
      "@type": "FAQPage",
      mainEntity: faqs.map(faq => ({
        "@type": "Question",
        name: faq.question,
        acceptedAnswer: {
          "@type": "Answer",
          text: faq.answer,
        },
      })),
    });
  }
  
  // 3. Service schema for each service
  if (location?.services) {
    for (const service of location.services) {
      schemas.push({
        "@context": "https://schema.org",
        "@type": "Service",
        serviceType: service,
        provider: {
          "@type": getBusinessType(practice.type),
          name: practice.name,
        },
        areaServed: {
          "@type": "City",
          name: location.city,
        },
      });
    }
  }
  
  return (
    <>
      {schemas.map((schema, i) => (
        <script
          key={i}
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
        />
      ))}
    </>
  );
}

7. Content Updates & ISR#

Revalidation Triggers#

Trigger Action Method
Content approved Revalidate site revalidatePath(/site/${slug})
GBP post published Update reviews widget ISR revalidation
New review received Update reviews widget ISR revalidation
Monthly refresh Full content rewrite ISR revalidation
Template change Re-render all sections ISR revalidation

Manual Revalidation API#

// src/app/api/revalidate/site/route.ts

import { revalidatePath } from "next/cache";

export async function POST(request: Request) {
  const { practiceSlug } = await request.json();
  
  revalidatePath(`/site/${practiceSlug}`);
  
  return Response.json({ 
    revalidated: true, 
    timestamp: new Date().toISOString() 
  });
}

8. Site Evolution Workflow#

Weekly AI-Driven Updates#

// src/server/inngest/functions/site-evolve.ts

export const siteEvolution = inngest.createFunction(
  { id: "site-evolution", retries: 3 },
  { cron: "0 2 * * 1" }, // Every Monday at 2 AM
  async ({ step }) => {
    const practices = await step.run("get-practices", async () => {
      return await db.practice.findMany({
        where: { sitePublished: true, autoUpdateEnabled: true },
        include: { locations: true },
      });
    });
    
    for (const practice of practices) {
      await step.run(`evolve-${practice.id}`, async () => {
        // 1. Get latest SEO data
        const rankings = await db.rankTracking.findMany({
          where: { practiceId: practice.id },
          include: { history: { orderBy: { checkedAt: "desc" }, take: 1 } },
        });
        
        // 2. Get GBP insights
        const insights = await db.gbpInsight.findMany({
          where: { gbpLocation: { gbpAccount: { practiceId: practice.id } } },
          orderBy: { date: "desc" },
          take: 7,
        });
        
        // 3. AI plans updates
        const updatePlan = await ai.generate({
          task: "site_evolution",
          system: "You are an SEO expert...",
          prompt: `Practice: ${practice.name}\nRankings: ${JSON.stringify(rankings)}\nGBP Insights: ${JSON.stringify(insights)}`,
          jsonMode: true,
        });
        
        // 4. Apply updates
        const plan = JSON.parse(updatePlan.text);
        for (const update of plan.updates) {
          await db.siteSection.updateMany({
            where: { practiceId: practice.id, sectionKey: update.section },
            data: { content: update.newContent },
          });
        }
        
        // 5. Revalidate
        await revalidateSite(practice.slug);
        
        // 6. Notify
        await email.send({
          to: practice.owner.email,
          subject: "Your website has been updated",
          body: `Changes: ${plan.updates.map((u: any) => u.reason).join("; ")}`,
        });
      });
    }
    
    return { evolved: practices.length };
  }
);

9. DNS Management API#

tRPC Router#

// src/server/api/routers/site.ts (DNS section)

setCustomDomain: practiceProcedure
  .input(z.object({ domain: z.string().regex(/^[a-z0-9][a-z0-9-]*\.[a-z]{2,}$/i) }))
  .mutation(async ({ ctx, input }) => {
    // Validate domain not already used
    const existing = await db.practice.findUnique({
      where: { customDomain: input.domain },
    });
    if (existing) throw new TRPCError({ code: "CONFLICT" });
    
    // Create DNS record
    await cloudflare.addCustomDomain(input.domain, ctx.practice.id);
    
    return { 
      status: "PENDING",
      dnsRecords: [
        { type: "CNAME", name: input.domain, value: "cname.rankflow.ai" },
      ],
      instructions: `Add a CNAME record pointing ${input.domain} to cname.rankflow.ai`,
    };
  }),

verifyCustomDomain: practiceProcedure
  .mutation(async ({ ctx }) => {
    if (!ctx.practice.customDomain) {
      throw new TRPCError({ code: "BAD_REQUEST", message: "No custom domain set" });
    }
    
    const isVerified = await cloudflare.verifyDomain(ctx.practice.customDomain);
    
    await db.practice.update({
      where: { id: ctx.practice.id },
      data: { domainStatus: isVerified ? "ACTIVE" : "FAILED" },
    });
    
    return { verified: isVerified };
  }),

10. Performance & SEO#

Performance Targets#

Metric Target Measurement
Time to First Byte < 200ms WebPageTest
Largest Contentful Paint < 2.5s Lighthouse
Cumulative Layout Shift < 0.1 Lighthouse
First Input Delay < 100ms Lighthouse

SEO Checklist#

Item Implementation
Meta title From hero h1 or practice name
Meta description Practice SEO description
Canonical URL Custom domain or subdomain
Robots index,follow when published
Open Graph Title, description, logo image
Twitter Cards Same as OG
Sitemap Auto-generated at /sitemap.xml
robots.txt Allow all when published
Schema.org Server-rendered JSON-LD
H1 per page Exactly one, in hero section
Alt text All images have descriptive alt
Internal links Services link to contact, etc.
Mobile-friendly Responsive design
Page speed Optimized images, minimal JS

Image Optimization#

// Next.js Image component with S3
import Image from "next/image";

<Image
  src={`https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${imageKey}`}
  alt="Dr. Smith Dental Clinic in Kochi"
  width={800}
  height={600}
  priority={isHero}
/>

End of Landing Page Hosting & DNS Documentation