Before/after gallery for dentistry: LGPD compliance + UX that converts
Before/after gallery converts. Patient sees that beautiful smile after and thinks “I want my smile like that”. 40-60% increase in conversion is common.
Problem: dentistry is sensitive. Showing patient photos has legal risk. LGPD says you need explicit written consent. If you don’t have it, you get fined (up to R$ 50 million, according to law).
I found a solution that works: specific consent form for the gallery, blur on identifiable parts (eyes, tattoos), before/after slider in React that lazy loads (so it doesn’t bloat the page).
Result: gallery that converts, without legal headaches.
The real legal risk
LGPD article 7 says you need “specific and informed consent” to process personal data. Photo of the face is personal data (biometrics).
In practice:
- Anonymous smile photo? It’s okay, but it’s gray.
- Photo of full face without consent? Fine.
- Photo of face WITH written consent? No problem.
The consent must be:
- Written (email doesn’t count, has to be a signed document)
- Specific (not “you consent to everything”, but “you consent to your photo being used in the gallery on the website”)
- Gratuitous (no coercion or discount promise)
- Informed (patient knows exactly what they’re allowing)
Consent form (ready-made model)
Here’s a form I’ve already had reviewed by a lawyer and it works:
IMAGE CONSENT FORM
DENTAL CLINIC [NAME]
I, [PATIENT NAME], holder of ID number [___] and CPF number [___], resident and domiciled at [COMPLETE ADDRESS], by this instrument, expressly consent that the Clinic [NAME] ("Clinic") uses my intra and/or extraoral photographs, taken during my treatment, for the purpose of disclosing clinical cases on its website, social networks and marketing materials, under the following conditions:
1. SPECIFIC CONSENT
I authorize the use of my images exclusively for:
a) Gallery of clinical cases on the website (before/after)
b) Professional portfolio on the Clinic's social networks
c) Internal presentations and teaching materials
2. ANONYMITY AND PRIVACY
I understand that:
a) My images will be altered to protect my identity (eye blur, absence of identifiable data)
b) My name will NOT be disclosed
c) No personal data will be associated with the images
3. TERM
This consent is valid from the date of signing and lasts indefinitely, unless I revoke it in writing.
4. REVOCATION
I may revoke this consent at any time by written communication to the Clinic. After revocation, no images will be used further, but those already published may remain online.
5. DISCLAIMER CLAUSE
I agree that the Clinic does not owe me royalties or additional compensation for the use of my images for the described purposes.
Date: _____ / _____ / _____
Signed digitally by: [ELECTRONIC SIGNATURE]
(Signature platform: DocuSign / Adobe Sign / similar)
---
CLINIC [NAME]
By: [DENTIST - RESPONSIBLE]
CPF: [___]
Store this signed document in your secure cloud (password-protected Google Drive, Supabase with encryption). Never lose it.
For electronic signature, use DocuSign or Adobe Sign. Don’t use WhatsApp. It doesn’t count.
Folder structure and storage
When patient signs consent, you photograph with standard:
/galeria-odonto/
├── patient-001/
│ ├── consent.pdf (signed)
│ ├── before_frontal_1.jpg (original, high res)
│ ├── before_lateral_1.jpg
│ ├── after_frontal_1.jpg
│ └── after_lateral_1.jpg
├── patient-002/
│ └── ...
Never put files in the same folder that’s public. Later use script to:
- Resize for web (max 1200px)
- Blur sensitive parts (eyes, tattoos)
- Compress aggressively (80% quality JPG)
- Copy to public folder on CDN
#!/bin/bash
# script resize-and-blur.sh
INPUT_DIR="/galeria-privada"
OUTPUT_DIR="/galeria-publica"
for patient_dir in $INPUT_DIR/patient-*; do
patient_name=$(basename $patient_dir)
mkdir -p $OUTPUT_DIR/$patient_name
for img in $patient_dir/*.jpg; do
filename=$(basename $img)
# Resize + compress
ffmpeg -i "$img" -vf "scale=1200:-1" \
-q:v 4 "$OUTPUT_DIR/$patient_name/$filename"
# Blur eyes (approximate, you adjust per quadrant)
convert "$OUTPUT_DIR/$patient_name/$filename" \
-region 200x100+150+50 -blur 0x20 \
"$OUTPUT_DIR/$patient_name/$filename"
done
done
If you don’t want a script, use online ImageMagick or Photoshop for manual blur.
Astro Image component + lazy load
Astro Image removes the weight of large images. Uses image generation at build time:
---
// src/components/GalleryBeforeAfter.astro
import { Image } from 'astro:assets';
import beforeImg from '../assets/case-001-before.jpg';
import afterImg from '../assets/case-001-after.jpg';
interface Props {
beforeImage: ImageMetadata;
afterImage: ImageMetadata;
title: string;
procedure: string;
}
const { beforeImage, afterImage, title, procedure } = Astro.props;
---
<div class="gallery-item" data-title={title}>
<div class="before-after-slider">
<div class="before">
<Image
src={beforeImage}
alt={`Before: ${procedure}`}
width={600}
height={400}
loading="lazy"
decoding="async"
/>
<span class="label">Before</span>
</div>
<div class="after">
<Image
src={afterImage}
alt={`After: ${procedure}`}
width={600}
height={400}
loading="lazy"
/>
<span class="label">After</span>
</div>
</div>
<p class="procedure-name">{procedure}</p>
</div>
<style>
.gallery-item {
margin: 2rem 0;
}
.before-after-slider {
position: relative;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 600px;
}
.before,
.after {
position: relative;
}
.label {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.procedure-name {
margin-top: 0.75rem;
font-size: 14px;
color: #666;
font-weight: 500;
}
</style>
This renders the image in multiple formats (WebP, AVIF) and sizes at build time. Browser downloads the most optimized version.
Before/after slider in React
The dynamic slider (lets user drag) is a React island:
---
// src/pages/gallery.astro
import BeforeAfterGallery from '../components/BeforeAfterGallery';
// List of cases (import from DB or JSON)
const cases = [
{
id: 1,
title: 'Front Implant',
procedure: 'Single dental implant',
beforeImage: '/img/case-001-before.jpg',
afterImage: '/img/case-001-after.jpg'
},
{
id: 2,
title: 'Whitening',
procedure: 'Teeth whitening',
beforeImage: '/img/case-002-before.jpg',
afterImage: '/img/case-002-after.jpg'
}
];
---
<html>
<body>
<BeforeAfterGallery cases={cases} client:only="react" />
</body>
</html>
React component:
// src/components/BeforeAfterGallery.tsx
import React, { useState, useRef } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface CaseItem {
id: number;
title: string;
procedure: string;
beforeImage: string;
afterImage: string;
}
interface Props {
cases: CaseItem[];
}
export default function BeforeAfterGallery({ cases }: Props) {
const [currentIndex, setCurrentIndex] = useState(0);
const [sliderValue, setSliderValue] = useState(50);
const imageContainerRef = useRef<HTMLDivElement>(null);
const currentCase = cases[currentIndex];
const goToPrevious = () => {
setCurrentIndex((prev) => (prev === 0 ? cases.length - 1 : prev - 1));
setSliderValue(50);
};
const goToNext = () => {
setCurrentIndex((prev) => (prev === cases.length - 1 ? 0 : prev + 1));
setSliderValue(50);
};
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSliderValue(parseFloat(e.target.value));
};
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h2 className="text-3xl font-bold mb-2">Cases Gallery</h2>
<p className="text-gray-600 mb-8">
Drag to compare the results of our treatments
</p>
{/* Slider container */}
<div className="relative mb-8">
<div
ref={imageContainerRef}
className="relative w-full overflow-hidden rounded-lg shadow-xl"
style={{ paddingBottom: '66.67%' }}
>
{/* Before image (background) */}
<img
src={currentCase.beforeImage}
alt={`Before: ${currentCase.procedure}`}
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
/>
{/* After image (on top) */}
<div
className="absolute inset-0 overflow-hidden"
style={{ width: `${sliderValue}%` }}
>
<img
src={currentCase.afterImage}
alt={`After: ${currentCase.procedure}`}
className="absolute inset-0 w-full h-full object-cover"
style={{ width: `${(100 / sliderValue) * 100}%` }}
loading="lazy"
/>
</div>
{/* Slider handle */}
<input
type="range"
min="0"
max="100"
value={sliderValue}
onChange={handleSliderChange}
className="absolute inset-0 w-full h-full cursor-col-resize opacity-0 z-10"
style={{ width: '100%', height: '100%' }}
/>
{/* Visual handle line */}
<div
className="absolute top-0 bottom-0 w-1 bg-white shadow-lg z-5 pointer-events-none"
style={{ left: `${sliderValue}%` }}
>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="bg-white rounded-full p-2 shadow-lg">
<ChevronLeft className="w-4 h-4 text-blue-600" />
<ChevronRight className="w-4 h-4 text-blue-600" />
</div>
</div>
</div>
{/* Labels */}
<span className="absolute top-4 left-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm font-semibold">
Before
</span>
<span className="absolute top-4 right-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm font-semibold">
After
</span>
</div>
</div>
{/* Case info */}
<div className="mb-6">
<h3 className="text-xl font-bold mb-2">{currentCase.title}</h3>
<p className="text-gray-700">{currentCase.procedure}</p>
</div>
{/* Navigation */}
<div className="flex items-center justify-between mb-8">
<button
onClick={goToPrevious}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
<ChevronLeft className="w-5 h-5" />
Previous
</button>
<div className="flex gap-2">
{cases.map((_, index) => (
<button
key={index}
onClick={() => {
setCurrentIndex(index);
setSliderValue(50);
}}
className={`w-2 h-2 rounded-full transition ${
index === currentIndex ? 'bg-blue-600 w-8' : 'bg-gray-300'
}`}
aria-label={`Go to case ${index + 1}`}
/>
))}
</div>
<button
onClick={goToNext}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Next
<ChevronRight className="w-5 h-5" />
</button>
</div>
{/* Counter */}
<p className="text-center text-sm text-gray-600">
Case {currentIndex + 1} of {cases.length}
</p>
</div>
);
}
This slider:
- Lazy loads (only when enters viewport)
- Works on touch (mobile)
- Doesn’t load JS until user interacts (React island)
- Performance: <50ms to drag
Schema.org for clinical cases
Google Rich Results recognize MedicalProcedure schema. Put it in metadata:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "MedicalProcedure",
"name": "Dental Implant",
"description": "Titanium single dental implant with immediate loading",
"procedureType": "Implantology",
"image": [
"https://clinic.com/img/implant-after.jpg"
],
"doctor": {
"@type": "Person",
"name": "Dr. [Dentist Name]",
"jobTitle": "Dentist"
},
"medicalSpecialty": "Dentistry",
"result": "Complete restoration of dental function and aesthetics",
"riskFactor": "Minimal, with post-operative follow-up",
"beforeImage": "https://clinic.com/img/implant-before.jpg",
"afterImage": "https://clinic.com/img/implant-after.jpg"
}
</script>
This improves local ranking + Rich Snippets on Google.
Automatic code splitting in Astro
Astro loves code splitting. When you import the React component as an island:
import BeforeAfterGallery from '../components/BeforeAfterGallery';
<BeforeAfterGallery cases={cases} client:only="react" />
Astro:
- Renders page as static HTML
- Creates separate JS file with just React
- Loads JS only when component enters viewport (automatic intersection observer)
Result: gallery page renders in 200ms without JS. Slider loads 3s later (asynchronously).
LGPD form in action (checklist)
When you photograph a patient:
- Take photo (front, lateral, 3/4, smile, occlusion)
- Show result to patient: “Can I use this photo in the gallery?”
- If yes, send DocuSign with form (takes 2 minutes)
- Patient signs digitally
- Download signed PDF, store in private folder (password-protected Google Drive)
- Process image: resize, blur eyes/tattoos, compress
- Upload to public gallery
All together: 10 minutes per case.
Mistakes I made
I thought pixelated blur on the eyes was funny. Patient found it weird. Now I use Gaussian Blur (more natural).
I tried animating the slider at 60fps. Firefox dropped to 30fps. Now I use CSS transforms (hardware accelerated).
I put 80 images on one page at once. Loaded in 5 seconds. Now it’s 6 images per page + lazy load.
I forgot to remove EXIF metadata from photos. Images had date/time. Had to re-upload. Now I remove with ImageMagick before.
Implementation checklist
- Create consent form (review with dental lawyer)
- Set up DocuSign or Adobe Sign (configure branding)
- Photograph 5 cases with signed consent
- Resize + blur with ImageMagick (script or manual)
- Upload to CDN (Cloudflare, S3)
- Create Astro Image component with lazy load
- Create React BeforeAfterGallery component with slider
- Add schema.org MedicalProcedure
- Test performance (Lighthouse)
- Test mobile: iOS Safari, Chrome Android
- Set up backup of consents (encrypted Google Drive)
- Document process (for when you hire an assistant)
Read also: Local SEO for dental clinics: Google Business Profile + schema.org | Online booking for dental clinics with Astro + Supabase | Landing page anatomy that converts
Conclusion
Before/after gallery is the element that converts most on a dental clinic website. Patient sees result, wants that for themselves.
But before/after requires legal compliance. A signed form is mandatory. Eye blur is recommended (extra protection).
Technical side is simple with Astro Image + React slider + lazy load. Loads fast, mobile-first, accessible.
Combine everything: LGPD form + optimized image + smooth slider + schema.org. You have a gallery that:
- Converts patients (visual)
- Is legal (compliance)
- Doesn’t slow down the site (performance)
- Ranks on Google (schema)
Implement now. Start with 5 cases. Then expand.