TL;DR: Building a free audit tool for your landing page means fighting CSS collisions, SSR failures, and SSRF vulnerabilities. An iframe solves isolation. A protected API route blocks attackers. Redis enforces rate limits. The entire setup runs at $0 on Vercel's free tier and handles 5,000 requests per day.
Embedding a working lite version of your SaaS product on your landing page is one of the highest-converting additions you can make. Users get instant value before signup. You build trust by letting them test the real thing. The upgrade path becomes obvious because they've already experienced what the full product does.
Tools like Website Audit Dev demonstrate this pattern well. A free scan delivers real value. Then it surfaces the full product at exactly the right moment. The conversion rate speaks for itself. But the technical implementation is where most teams stumble. This guide shows you how to build and deploy one the right way, without breaking your site in production.
The Naive Approach Breaks in Production Every Time
The simplest idea is to take your standalone HTML tool and paste it directly into your site's custom code panel or a Next.js component. You preview it locally. It renders. You deploy. The build succeeds. You open the production URL and everything is broken.
This pattern repeats so predictably that you can set your watch by it. The reasons become obvious once you've hit them, but they're invisible until that moment.
Your site has global CSS classes. Classes like .layout, .content, .grid, .button, and .card are defined in your site's stylesheet. Your tool also has .layout, .content, .grid, .button, and .card. They collide. Your tool's button suddenly has your site's hover state. Your site's grid now has your tool's column sizing. The cascade is fighting itself and there's no clean winner.
Your site has a dark theme. Your tool expects light. The tool's white text on a white background becomes invisible. Or your site's dark background bleeds through the tool's transparent sections and makes the UI unreadable.
Your tool uses localStorage to cache results. This works in the browser. It throws during server-side rendering because window and localStorage don't exist on the server. Next.js tries to render the component at build time, hits localStorage.getItem(), and the build fails with a cryptic error about undefined properties.
Your tool loads Three.js via a runtime-injected script tag to render a WebGL globe. Your site has a Content Security Policy header that blocks inline scripts and third-party script sources. The browser silently refuses to execute the injected script. Your globe never appears. The console shows a CSP violation but only if you know to check the Network tab's failed requests.
And if any of this produces malformed HTML, like an unclosed tag or a mismatched quote, Vercel's build pipeline kills the deploy. You get
next build exited with 1and no useful error message.
The browser would have silently recovered from the same HTML. Vercel does not.
The Fix Is an Iframe
The fix is an <iframe>. It sounds old-fashioned, but it's exactly right here.
An iframe is a fully isolated browser context. Your site's CSS cannot reach inside it. Your site's JavaScript cannot touch its DOM. Your site's theme, CSP headers, and global state are invisible to the iframe's content. The tool renders exactly as it does standalone, pixel-perfect, every time.
The setup has three parts. Each one is simple. Together they solve the entire class of problems.
The Tool Itself
The tool is a single HTML file. It contains all its CSS in a <style> block and all its JavaScript in a <script> block. No external dependencies unless you need them. If you do need a library like Three.js or Chart.js, load it from a CDN with an integrity hash.
This file lives in your Next.js project's public/ folder. Vercel serves everything in public/ as static files at the root path. If you put audit-tool.html in public/, it's available at https://yoursite.com/audit-tool.html. No routing config needed.
Your landing page embeds it with one iframe tag:
html
<iframe
src="/audit-tool.html"
width="100%"
height="600"
frameborder="0"
sandbox="allow-scripts allow-same-origin"
title="Free Audit Tool">
</iframe>
The sandbox attribute is key. allow-scripts lets the tool's JavaScript run. allow-same-origin lets it access localStorage and make fetch calls to your API route. Without allow-same-origin, the iframe is treated as cross-origin even though it's on the same domain, and fetch calls to /api/* fail with CORS errors.
The Backend Route
The backend route is a Next.js App Router serverless function at app/api/audit/route.js. Its only job is to fetch a URL server-side and return the HTML response plus the real HTTP response headers.
This matters because browsers hide cross-origin response headers from client-side JavaScript. If your tool fetches https://example.com from the browser, the response headers like Strict-Transport-Security, X-Frame-Options, and Content-Security-Policy are invisible. The browser returns an opaque response object and response.headers.get('x-frame-options') is always null.
Security checks that rely on HTTP headers can only be read when fetched from a server. That's the entire reason this route exists.
The route also enforces rate limiting, which we'll cover in the next section.
Why Vercel's Free Tier Doesn't Give You Rate Limiting
Vercel's free tier does not support built-in rate limiting through vercel.json configuration or dashboard settings. You can't add a rate limit rule that says "block IPs that hit this route more than 30 times in 60 seconds."
The second problem is that Vercel serverless functions are stateless. They reset on every cold start. If you try to implement in-memory rate limiting like this:
``javascript
// This DOES NOT WORK in production
const requestCounts = {};
export async function GET(req) {
const ip = req.headers.get('x-forwarded-for');
requestCounts[ip] = (requestCounts[ip] || 0) + 1;
if (requestCounts[ip] > 30) {
return new Response('Rate limit exceeded', { status: 429 });
}
// ... rest of logic
}
``
This works locally. It fails in production because requestCounts is wiped on every cold start. A cold start happens when Vercel spins up a new instance of your function. This happens unpredictably, often multiple times per day. The attacker sends 10 requests, triggers a cold start, and the counter resets to zero. They repeat this forever.
You need persistent storage. That storage needs to be fast enough to respond in under 50ms so it doesn't add noticeable latency to every request. Redis is the right answer here.
How Upstash Redis Solves This
Upstash offers a Redis service with a free tier that provides 10,000 commands per day. Each audit request consumes 2 Redis commands: one GET to read the current count for an IP, and one INCR to increment it. At 10,000 commands per day, the system can handle 5,000 audit requests per day before hitting the free tier limit.
The rate limit is set at 30 requests per IP address within 60 seconds before returning a 429 response. This is enough to block simple abuse while allowing legitimate users to run multiple tests.
Here's the full implementation:
``javascript
// app/api/audit/route.js
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
export async function GET(req) {
const ip = req.headers.get('x-forwarded-for') || 'unknown';
const rateKey = rate:${ip};
// Check current count
const current = await redis.get(rateKey);
if (current && current >= 30) {
return new Response('Rate limit exceeded. Try again in 60 seconds.', {
status: 429
});
}
// Increment and set expiry if first request
const count = await redis.incr(rateKey);
if (count === 1) {
await redis.expire(rateKey, 60);
}
// ... rest of audit logic
}
``
The expire call sets a 60-second TTL on the key. After 60 seconds, Redis automatically deletes the key. The next request from that IP starts fresh at count 1.
SSRF Protection: Why Your API Route Is a Security Hole Without It
A server-side fetch endpoint without SSRF protection is a security vulnerability. SSRF stands for Server-Side Request Forgery. It means an attacker can make your server fetch internal URLs that are not accessible from the public internet.
Here's what happens if you don't protect it:
``javascript
// VULNERABLE CODE - DO NOT USE
export async function GET(req) {
const { searchParams } = new URL(req.url);
const targetUrl = searchParams.get('url');
const response = await fetch(targetUrl);
const html = await response.text();
return new Response(html);
}
``
An attacker sends this request:
http
GET /api/audit?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
That IP address is AWS's EC2 metadata endpoint. It's only accessible from inside an EC2 instance. If your Vercel function runs on AWS infrastructure (which it often does), this request succeeds. The attacker gets temporary AWS credentials. They use those credentials to access your S3 buckets, RDS databases, and Lambda functions.
The same attack works with internal services. If your company has an internal admin panel at http://admin.internal.company.com, an attacker can make your server fetch it and return the HTML. They now have access to internal tools that should never be public.
The Five Defensive Layers
The solution is to validate and sanitize every URL before fetching it. Here's the full defensive stack:
``javascript
// app/api/audit/route.js (continued)
const BLOCKED_IPS = [
'127.0.0.0/8', // localhost
'169.254.0.0/16', // AWS metadata
'10.0.0.0/8', // private network
'172.16.0.0/12', // private network
'192.168.0.0/16', // private network
];
function isBlockedIP(hostname) {
// Parse hostname to IP if it's an IP address
const ipRegex = /^(\d{1,3}.){3}\d{1,3}$/;
if (!ipRegex.test(hostname)) return false;
const parts = hostname.split('.').map(Number);
// Check against each blocked range
if (parts[0] === 127) return true; // localhost
if (parts[0] === 169 && parts[1] === 254) return true; // AWS metadata
if (parts[0] === 10) return true; // private
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] === 192 && parts[1] === 168) return true;
return false;
}
export async function GET(req) {
const { searchParams } = new URL(req.url);
const targetUrl = searchParams.get('url');
// Defense 1: Reject missing or empty URLs
if (!targetUrl) {
return new Response('Missing url parameter', { status: 400 });
}
// Defense 2: Cap URL length to prevent attack payloads
if (targetUrl.length > 2048) {
return new Response('URL too long', { status: 400 });
}
// Defense 3: Parse and validate the URL
let parsedUrl;
try {
parsedUrl = new URL(targetUrl);
} catch (e) {
return new Response('Invalid URL format', { status: 400 });
}
// Defense 4: Block non-HTTP(S) protocols
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return new Response('Only HTTP(S) allowed', { status: 400 });
}
// Defense 5: Block private/internal IPs
if (isBlockedIP(parsedUrl.hostname)) {
return new Response('Access to internal resources blocked', { status: 403 });
}
// All checks passed, safe to fetch
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(parsedUrl.href, { signal: controller.signal });
const html = await response.text();
return new Response(JSON.stringify({
html,
headers: Object.fromEntries(response.headers.entries()),
status: response.status,
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (e) {
if (e.name === 'AbortError') {
return new Response('Request timeout', { status: 504 });
}
return new Response('Fetch failed', { status: 500 });
} finally {
clearTimeout(timeout);
}
}
``
URL length is capped at 2,048 characters to prevent attack payloads. Fetch timeout is enforced at 10 seconds using AbortController. If the target site doesn't respond within 10 seconds, the request is aborted and the user gets a 504 error.
What Breaks and How to Debug It
Here's what goes wrong in production and how to fix it.
CSS Collisions
Symptom: Your tool's buttons have the wrong color. Your site's font leaks into the tool. The layout shifts unexpectedly.
Cause: Your site's global CSS is bleeding into the iframe.
Fix: Make sure the iframe has sandbox="allow-scripts allow-same-origin". If the problem persists, the tool's HTML might be loading your site's stylesheet. Check the <head> block in audit-tool.html. Remove any <link rel="stylesheet"> tags that point to your site's CSS.
CORS Errors
Symptom: The browser console shows Blocked by CORS policy. The tool can't fetch data from your API route.
Cause: The iframe doesn't have allow-same-origin in the sandbox attribute.
Fix: Add allow-same-origin to the iframe tag:
<iframe sandbox="allow-scripts allow-same-origin" ...></iframe>
For more detailed security patterns in user-facing tools, Security Audit Dev demonstrates a similar multi-layer validation approach before running any automated checks.
Rate Limiting Doesn't Work
Symptom: You've added the Redis rate limiting code but users can still send unlimited requests.
Cause: The environment variables aren't set in production.
Fix: Go to your Vercel dashboard. Click your project. Click "Settings" → "Environment Variables". Add UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. Redeploy.
Fetch Returns 403 for Valid URLs
Symptom: Your tool returns "Access to internal resources blocked" for legitimate public websites.
Cause: The isBlockedIP function is blocking valid hostnames.
Fix: The current implementation only blocks numeric IPs. If you're seeing false positives, check if the hostname resolves to a private IP. You might need to add a DNS lookup step and validate the resolved IP instead of the hostname.
The Full Deployment Checklist
Here's the step-by-step process to ship this:
Create the tool HTML file. Put it in
public/audit-tool.html. Test it standalone by openinghttp://localhost:3000/audit-tool.htmlin your browser.Add the iframe to your landing page. Use the full
sandboxattribute shown earlier.Create the API route. Put the full code from this guide in
app/api/audit/route.js.Sign up for Upstash. Create a new Redis database. Copy the REST URL and REST token.
Set environment variables. Add
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENto your local.env.localand to Vercel's dashboard.Test locally. Run
npm run dev. Open your landing page. Try the tool. Check the Network tab for errors.Deploy to Vercel. Run
vercel --prod. Open the production URL. Test again.Monitor rate limits. Check your Upstash dashboard to see how many commands you're using per day. If you hit the free tier limit, upgrade or adjust your rate limit values.
Building a free audit tool is one of the highest-leverage additions to your SaaS landing page. The iframe gives you perfect isolation. The API route blocks SSRF attacks. Redis enforces rate limits. The entire setup runs at $0 on Vercel's free tier and handles 5,000 requests per day.
For more on building automated workflows that tie into tools like this, check out this step-by-step guide on building your first automated workflow. And if you're looking for more ways to optimize your landing page strategy, the principles in landing page best practices apply directly here.
📦 Publishing Kit — Dev.to
Title Options (5)
Selected: How to Embed a Free SaaS Audit Tool on Your Landing Page Without Breaking Production
Alternates:
- Building a Landing Page Audit Tool That Survives CSS Collisions, SSR, and Security Attacks
- The Right Way to Embed a Free Tool Demo on Your SaaS Landing Page (Iframe + Redis + Vercel)
- Stop Breaking Your Landing Page: How to Safely Embed a Free Audit Tool Using Iframes
- From CSS Chaos to Production-Ready: Building a Free Landing Page Audit Tool for $0
Slug
embed-free-saas-audit-tool-landing-page-iframe-vercel
Tags
webdev, tutorial, performance, iframe











Top comments (0)