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 };
}),
| 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