Multi‑Tenant Landing Page Builder in Nuxt 4 + TypeScript + Tailwind

Build a multi‑tenant landing page builder with per‑domain theming, dynamic content blocks, and ISR using Nuxt 4, TypeScript, Tailwind, and Nitro storage.
Tags: Nuxt 4, TypeScript, Tailwind, Multi‑Tenant, ISR
Time to read: 12 min
What we’ll build
- Resolve the tenant from subdomain (e.g. acme.localhost:3000) or query (?t=acme)
- Per‑tenant theme via CSS variables wired into Tailwind
- JSON‑driven landing pages composed of reusable blocks (hero, features, CTA, pricing)
- Simple admin builder to add/reorder/save blocks
- Incremental Static Regeneration (ISR) with Nuxt 4 route rules
- Nitro storage (filesystem) for easy local development
This project leans on Nuxt 4’s improved server runtime and route rules for ISR, plus Nitro’s first‑class unstorage integration to keep content simple and fast.
Prerequisites
- Node.js 18+
- Basic familiarity with Nuxt, Vue SFCs, and Tailwind CSS
- A terminal and your favorite editor
1) Create the Nuxt 4 app
npx nuxi@latest init nuxt-multitenant
cd nuxt-multitenant
npm install
npm run dev
Visit http://localhost:3000 to confirm the app runs.
2) Add Tailwind and friends
Install Tailwind and the official Nuxt module:
npm i -D @nuxtjs/tailwindcss @tailwindcss/typography
Enable the module in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
// Nitro storage (filesystem) + route rules for ISR
nitro: {
storage: {
data: {
driver: 'fs',
base: './.data',
},
},
routeRules: {
// Cache public pages with ISR for 60s
'/': { isr: 60 },
'/:slug': { isr: 60 },
// Always render admin live (no ISR)
'/admin/**': { ssr: true },
'/api/**': { ssr: true },
},
},
typescript: {
strict: true,
},
runtimeConfig: {
adminToken: process.env.ADMIN_TOKEN || 'dev-token', // optional, demo only
},
})
Create tailwind.config.ts:
// tailwind.config.ts
import type { Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
export default <Partial<Config>>{
content: [
'./app.vue',
'./components/**/*.{vue,js,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.ts',
],
theme: {
extend: {
// Use CSS variables so tenants can theme without rebuilds
colors: {
primary: 'rgb(var(--color-primary) / <alpha-value>)',
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
},
},
},
plugins: [typography],
}
Add Tailwind base to app.vue:
<!-- app.vue -->
<template>
<NuxtLayout />
</template>
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>
3) Type models for tenants and blocks
Create types/tenant.ts:
// types/tenant.ts
export interface TenantTheme {
// values are CSS RGB "r g b" (no commas), e.g. "59 130 246" for Tailwind's blue-500
primary: string
secondary: string
}
export interface Tenant {
id: string
name: string
domain?: string
logo?: string
theme: TenantTheme
}
Create types/blocks.ts:
// types/blocks.ts
export type BlockType = 'hero' | 'features' | 'cta' | 'pricing'
export interface BaseBlock {
id: string
type: BlockType
}
export interface HeroBlock extends BaseBlock {
type: 'hero'
headline: string
subheadline?: string
image?: string
cta?: { label: string; href: string }
}
export interface FeaturesBlock extends BaseBlock {
type: 'features'
items: { title: string; description: string; icon?: string }[]
}
export interface CtaBlock extends BaseBlock {
type: 'cta'
text: string
button: { label: string; href: string }
}
export interface PricingBlock extends BaseBlock {
type: 'pricing'
plans: { name: string; price: string; features: string[]; cta: string }[]
}
export type Block = HeroBlock | FeaturesBlock | CtaBlock | PricingBlock
export interface Page {
slug: string
blocks: Block[]
}
Augment the H3 context for a typed tenantId. Create types/tenant-context.d.ts:
// types/tenant-context.d.ts
import 'h3'
declare module 'h3' {
interface H3EventContext {
tenantId?: string
}
}
Nuxt automatically picks up types/*.d.ts.
4) Resolve the tenant from host or query
Create a server plugin that sets event.context.tenantId:
// server/plugins/tenant.ts
import { getRequestHeader } from 'h3'
import { getQuery, defineEventHandler } from '#imports'
export default defineEventHandler((event) => {
// Example: acme.localhost:3000 → "acme"
const host = getRequestHeader(event, 'host') || 'localhost'
const hostname = host.split(':')[0]
const maybeSub = hostname.split('.')[0]
const q = getQuery(event)
const viaQuery = typeof q.t === 'string' && q.t.length > 0 ? q.t : undefined
// Prioritize ?t=tenant in local dev, else subdomain; fallback to "demo"
const tenantId =
viaQuery ||
(maybeSub && maybeSub !== 'localhost' && maybeSub !== 'www' ? maybeSub : 'demo')
event.context.tenantId = tenantId
})
5) Seed demo tenants and pages
Use Nitro storage (filesystem) to keep data under .data/.
// server/plugins/seed.ts
import { defineNitroPlugin } from 'nitropack'
import { useStorage } from '#imports'
import type { Tenant } from '~/types/tenant'
import type { Page } from '~/types/blocks'
const demoTenants: Tenant[] = [
{
id: 'demo',
name: 'Demo Co.',
theme: { primary: '59 130 246', secondary: '16 185 129' }, // blue-500, emerald-500
logo: 'https://dummyimage.com/64x64/000/fff&text=D',
},
{
id: 'acme',
name: 'ACME Inc.',
theme: { primary: '234 88 12', secondary: '217 119 6' }, // orange-600, amber-600
logo: 'https://dummyimage.com/64x64/000/fff&text=A',
},
{
id: 'globex',
name: 'Globex',
theme: { primary: '147 51 234', secondary: '59 130 246' }, // purple-600, blue-500
logo: 'https://dummyimage.com/64x64/000/fff&text=G',
},
]
function defaultHomePage(tenantId: string): Page {
return {
slug: 'home',
blocks: [
{
id: `${tenantId}-hero`,
type: 'hero',
headline: `Welcome to ${tenantId.toUpperCase()}`,
subheadline: 'Ship landing pages for multiple tenants with one codebase.',
cta: { label: 'Get Started', href: '#get-started' },
},
{
id: `${tenantId}-features`,
type: 'features',
items: [
{ title: 'Per‑domain themes', description: 'Brand colors per tenant.' },
{ title: 'Block‑based', description: 'Compose pages from reusable blocks.' },
{ title: 'Fast by default', description: 'ISR caches pages automatically.' },
],
},
{
id: `${tenantId}-cta`,
type: 'cta',
text: 'Ready to launch your next landing page?',
button: { label: 'Deploy', href: '#deploy' },
},
],
}
}
export default defineNitroPlugin(async () => {
const storage = useStorage('data')
for (const t of demoTenants) {
const tenantKey = `tenants/${t.id}.json`
const exists = await storage.hasItem(tenantKey)
if (!exists) {
await storage.setItem(tenantKey, t)
}
const pageKey = `pages/${t.id}/home.json`
const pageExists = await storage.hasItem(pageKey)
if (!pageExists) {
await storage.setItem(pageKey, defaultHomePage(t.id))
}
}
})
6) API: get tenant and pages, save pages
// server/api/tenant.get.ts
import { createError } from 'h3'
import { useStorage, defineEventHandler } from '#imports'
import type { Tenant } from '~/types/tenant'
export default defineEventHandler(async (event) => {
const tenantId = event.context.tenantId || 'demo'
const storage = useStorage('data')
const tenant = await storage.getItem<Tenant>(`tenants/${tenantId}.json`)
if (!tenant) {
throw createError({ statusCode: 404, statusMessage: 'Tenant not found' })
}
return tenant
})
// server/api/pages/[slug].get.ts
import { getRouterParam } from 'h3'
import { useStorage, defineEventHandler } from '#imports'
import type { Page } from '~/types/blocks'
export default defineEventHandler(async (event) => {
const slug = (getRouterParam(event, 'slug') || 'home') as string
const tenantId = event.context.tenantId || 'demo'
const storage = useStorage('data')
const page = await storage.getItem<Page>(`pages/${tenantId}/${slug}.json`)
return page || { slug, blocks: [] }
})
// server/api/pages/[slug].put.ts
import { getRouterParam, readBody, createError } from 'h3'
import { useRuntimeConfig, useStorage, defineEventHandler } from '#imports'
import type { Page } from '~/types/blocks'
export default defineEventHandler(async (event) => {
const slug = (getRouterParam(event, 'slug') || 'home') as string
const tenantId = event.context.tenantId || 'demo'
const storage = useStorage('data')
// Simple token check (demo only)
const { adminToken } = useRuntimeConfig()
const token = event.node.req.headers['x-admin-token']
if (adminToken && token !== adminToken) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const body = await readBody<Page>(event)
if (!body || !Array.isArray(body.blocks)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
}
const toSave: Page = { slug, blocks: body.blocks }
await storage.setItem(`pages/${tenantId}/${slug}.json`, toSave)
return { ok: true }
})
Optional: create tenants programmatically.
// server/api/admin/tenants.post.ts
import { readBody, createError } from 'h3'
import { useRuntimeConfig, useStorage, defineEventHandler } from '#imports'
import type { Tenant } from '~/types/tenant'
import type { Page } from '~/types/blocks'
export default defineEventHandler(async (event) => {
const { adminToken } = useRuntimeConfig()
const token = event.node.req.headers['x-admin-token']
if (adminToken && token !== adminToken) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const body = await readBody<{ tenant: Tenant; page?: Page }>(event)
if (!body?.tenant?.id) {
throw createError({ statusCode: 400, statusMessage: 'Missing tenant id' })
}
const storage = useStorage('data')
const key = `tenants/${body.tenant.id}.json`
const exists = await storage.hasItem(key)
if (exists) {
throw createError({ statusCode: 409, statusMessage: 'Tenant exists' })
}
await storage.setItem(key, body.tenant)
if (body.page) {
await storage.setItem(`pages/${body.tenant.id}/${body.page.slug}.json`, body.page)
}
return { ok: true }
})
7) Default layout with per‑tenant theming
We’ll fetch the tenant on the server and set CSS variables that Tailwind consumes.
<!-- layouts/default.vue -->
<script setup lang="ts">
import type { Tenant } from '~/types/tenant'
const { data: tenant } = await useAsyncData<Tenant>('tenant', () => $fetch('/api/tenant'))
const cssVars = computed(() => ({
'--color-primary': tenant.value?.theme?.primary ?? '59 130 246',
'--color-secondary': tenant.value?.theme?.secondary ?? '16 185 129',
}))
useHead({
titleTemplate: (title) => (title ? `${title} · ${tenant.value?.name ?? 'Site'}` : tenant.value?.name ?? 'Site'),
})
</script>
<template>
<div :style="cssVars" class="min-h-screen bg-white text-gray-900">
<header class="border-b bg-white/80 backdrop-blur sticky top-0 z-10">
<div class="mx-auto max-w-5xl px-6 py-4 flex items-center gap-4">
<img v-if="tenant?.logo" :src="tenant!.logo" alt="" class="h-8 w-8 rounded" />
<strong class="text-lg">{{ tenant?.name ?? 'Demo' }}</strong>
<NuxtLink to="/admin" class="ml-auto text-sm text-primary hover:underline">Admin</NuxtLink>
</div>
</header>
<main class="mx-auto max-w-5xl px-6 py-10">
<NuxtPage />
</main>
</div>
</template>
8) Landing page and block renderer
<!-- pages/index.vue -->
<script setup lang="ts">
import type { Page } from '~/types/blocks'
const { data: page } = await useAsyncData<Page>('page:home', () => $fetch('/api/pages/home'))
</script>
<template>
<div>
<BlocksRenderer v-if="page" :blocks="page.blocks" />
</div>
</template>
<!-- components/BlocksRenderer.vue -->
<script setup lang="ts">
import type { Block } from '~/types/blocks'
import Hero from '~/components/blocks/Hero.vue'
import Features from '~/components/blocks/Features.vue'
import Cta from '~/components/blocks/Cta.vue'
import Pricing from '~/components/blocks/Pricing.vue'
const props = defineProps<{ blocks: Block[] }>()
function resolve(type: Block['type']) {
switch (type) {
case 'hero': return Hero
case 'features': return Features
case 'cta': return Cta
case 'pricing': return Pricing
default: return null
}
}
</script>
<template>
<div class="space-y-16">
<component
v-for="b in blocks"
:key="b.id"
:is="resolve(b.type)"
:block="b"
/>
</div>
</template>
Block components:
<!-- components/blocks/Hero.vue -->
<script setup lang="ts">
import type { HeroBlock } from '~/types/blocks'
defineProps<{ block: HeroBlock }>()
</script>
<template>
<section class="text-center">
<h1 class="text-4xl sm:text-5xl font-bold tracking-tight">
{{ block.headline }}
</h1>
<p v-if="block.subheadline" class="mt-4 text-lg text-gray-600">
{{ block.subheadline }}
</p>
<div class="mt-8">
<a
v-if="block.cta"
:href="block.cta.href"
class="inline-flex items-center rounded-md bg-primary px-5 py-3 font-medium text-white shadow hover:opacity-90"
>
{{ block.cta.label }}
</a>
</div>
</section>
</template>
<!-- components/blocks/Features.vue -->
<script setup lang="ts">
import type { FeaturesBlock } from '~/types/blocks'
defineProps<{ block: FeaturesBlock }>()
</script>
<template>
<section>
<div class="grid sm:grid-cols-2 gap-6">
<div
v-for="(item, i) in block.items"
:key="i"
class="rounded-lg border p-5 hover:shadow-sm transition"
>
<div class="font-semibold text-lg text-primary">{{ item.title }}</div>
<p class="text-gray-600 mt-2">{{ item.description }}</p>
</div>
</div>
</section>
</template>
<!-- components/blocks/Cta.vue -->
<script setup lang="ts">
import type { CtaBlock } from '~/types/blocks'
defineProps<{ block: CtaBlock }>()
</script>
<template>
<section class="rounded-xl border p-8 text-center bg-primary/5">
<p class="text-lg">{{ block.text }}</p>
<a
class="mt-4 inline-flex items-center rounded-md bg-primary px-5 py-3 font-medium text-white shadow hover:opacity-90"
:href="block.button.href"
>
{{ block.button.label }}
</a>
</section>
</template>
<!-- components/blocks/Pricing.vue -->
<script setup lang="ts">
import type { PricingBlock } from '~/types/blocks'
defineProps<{ block: PricingBlock }>()
</script>
<template>
<section class="grid sm:grid-cols-3 gap-6">
<div
v-for="(plan, i) in block.plans"
:key="i"
class="rounded-xl border p-6 flex flex-col"
>
<h3 class="text-lg font-semibold">{{ plan.name }}</h3>
<div class="mt-2 text-3xl font-bold text-primary">{{ plan.price }}</div>
<ul class="mt-4 space-y-2 text-gray-700">
<li v-for="(f, j) in plan.features" :key="j">• {{ f }}</li>
</ul>
<button class="mt-6 rounded-md bg-secondary text-white px-4 py-2 hover:opacity-90">
{{ plan.cta }}
</button>
</div>
</section>
</template>
9) Simple Admin Builder
A lightweight page to add/reorder/save blocks for the current tenant.
<!-- pages/admin.vue -->
<script setup lang="ts">
import { nanoid } from 'nanoid/non-secure'
import type { Page, Block } from '~/types/blocks'
import type { Tenant } from '~/types/tenant'
const slug = ref('home')
const { data: tenant } = await useAsyncData<Tenant>('tenant', () => $fetch('/api/tenant'))
const { data: page, refresh } = await useAsyncData<Page>('admin:page', () => $fetch(`/api/pages/${slug.value}`), { watch: [slug] })
function addBlock(type: Block['type']) {
if (!page.value) return
const id = nanoid()
const defaults: Record<Block['type'], Block> = {
hero: { id, type: 'hero', headline: 'New hero', subheadline: '' },
features: { id, type: 'features', items: [{ title: 'Feature', description: 'Describe it' }] },
cta: { id, type: 'cta', text: 'Call to action', button: { label: 'Click', href: '#' } },
pricing: { id, type: 'pricing', plans: [{ name: 'Pro', price: '$19/mo', features: ['Feature A', 'Feature B'], cta: 'Choose Pro' }] },
}
page.value.blocks.push(defaults[type])
}
function move(idx: number, dir: -1 | 1) {
if (!page.value) return
const to = idx + dir
if (to < 0 || to >= page.value.blocks.length) return
const arr = page.value.blocks
const [b] = arr.splice(idx, 1)
arr.splice(to, 0, b)
}
function remove(idx: number) {
if (!page.value) return
page.value.blocks.splice(idx, 1)
}
async function save() {
if (!page.value) return
try {
await $fetch(`/api/pages/${slug.value}`, {
method: 'PUT',
body: page.value,
headers: { 'x-admin-token': import.meta.client ? (localStorage.getItem('adminToken') || '') : '' },
})
await refresh()
alert('Saved!')
} catch (e) {
console.error(e)
alert('Failed to save')
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-semibold">Admin · {{ tenant?.name }}</h1>
<p class="text-gray-600 mt-1">Editing slug: <code>{{ slug }}</code></p>
<div class="mt-6 flex gap-2">
<button class="px-3 py-2 rounded border hover:bg-gray-50" @click="addBlock('hero')">+ Hero</button>
<button class="px-3 py-2 rounded border hover:bg-gray-50" @click="addBlock('features')">+ Features</button>
<button class="px-3 py-2 rounded border hover:bg-gray-50" @click="addBlock('cta')">+ CTA</button>
<button class="px-3 py-2 rounded border hover:bg-gray-50" @click="addBlock('pricing')">+ Pricing</button>
<button class="ml-auto px-4 py-2 rounded bg-primary text-white" @click="save">Save</button>
</div>
<div v-if="page" class="mt-6 space-y-4">
<div
v-for="(b, i) in page.blocks"
:key="b.id"
class="rounded border p-4"
>
<div class="flex items-center gap-2">
<span class="text-sm uppercase tracking-wider text-gray-500">{{ b.type }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ b.id }}</span>
<div class="ml-auto flex gap-2">
<button class="px-2 py-1 rounded border" @click="move(i, -1)">↑</button>
<button class="px-2 py-1 rounded border" @click="move(i, 1)">↓</button>
<button class="px-2 py-1 rounded border text-red-600" @click="remove(i)">Delete</button>
</div>
</div>
<!-- Minimal inline editors -->
<div v-if="b.type === 'hero'" class="mt-3 space-y-2">
<input v-model="(b as any).headline" class="w-full rounded border px-3 py-2" placeholder="Headline" />
<input v-model="(b as any).subheadline" class="w-full rounded border px-3 py-2" placeholder="Subheadline" />
<div class="flex gap-2">
<input v-model="(b as any).cta?.label" class="flex-1 rounded border px-3 py-2" placeholder="CTA label" />
<input v-model="(b as any).cta?.href" class="flex-1 rounded border px-3 py-2" placeholder="CTA href" />
</div>
</div>
<div v-else-if="b.type === 'features'" class="mt-3 space-y-3">
<div v-for="(it, j) in (b as any).items" :key="j" class="grid grid-cols-2 gap-2">
<input v-model="it.title" class="rounded border px-3 py-2" placeholder="Title" />
<input v-model="it.description" class="rounded border px-3 py-2" placeholder="Description" />
</div>
<button class="mt-2 px-3 py-2 rounded border" @click="(b as any).items.push({ title: 'New', description: 'Describe' })">+ Item</button>
</div>
<div v-else-if="b.type === 'cta'" class="mt-3 space-y-2">
<input v-model="(b as any).text" class="w-full rounded border px-3 py-2" placeholder="Text" />
<div class="flex gap-2">
<input v-model="(b as any).button.label" class="flex-1 rounded border px-3 py-2" placeholder="Button label" />
<input v-model="(b as any).button.href" class="flex-1 rounded border px-3 py-2" placeholder="Button href" />
</div>
</div>
<div v-else-if="b.type === 'pricing'" class="mt-3 space-y-4">
<div v-for="(p, j) in (b as any).plans" :key="j" class="rounded border p-3">
<div class="grid grid-cols-3 gap-2">
<input v-model="p.name" class="rounded border px-3 py-2" placeholder="Plan name" />
<input v-model="p.price" class="rounded border px-3 py-2" placeholder="Price" />
<input v-model="p.cta" class="rounded border px-3 py-2" placeholder="CTA" />
</div>
<textarea v-model="(p.features as string[]).join('\n')" @change="p.features = ($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" class="mt-2 w-full rounded border px-3 py-2" rows="3" placeholder="One feature per line"></textarea>
</div>
<button class="px-3 py-2 rounded border" @click="(b as any).plans.push({ name: 'New', price: '$0', features: [], cta: 'Choose' })">+ Plan</button>
</div>
</div>
</div>
<div class="mt-10">
<h2 class="text-xl font-semibold mb-3">Preview</h2>
<BlocksRenderer v-if="page" :blocks="page.blocks" />
</div>
</div>
</template>
Install nanoid for quick IDs:
npm i nanoid
10) Try it out
- Start dev server:
npm run dev
- Open tenants:
- Demo: http://demo.localhost:3000
- ACME: http://acme.localhost:3000
- Globex: http://globex.localhost:3000
If your browser doesn’t like subdomains in your environment, append a query param:
- http://localhost:3000/?t=acme
- Visit the builder at /admin:
- http://acme.localhost:3000/admin
- Click “+ Hero / Features / CTA / Pricing”, reorder blocks, and Save.
- For the demo token check, you can set an admin token in env:
- Start with: ADMIN_TOKEN=my-secret npm run dev
- In the admin page, before saving once, run in devtools console: localStorage.setItem('adminToken', 'my-secret')
- ISR in action:
- Public pages (/, /:slug) are cached for 60s via route rules.
- Edits in /admin write to storage immediately; after the ISR window, fresh requests pick up the changes.
11) Deployment notes
- Nitro storage:
- For production, switch storage to a shared backend (Redis, Cloudflare KV, S3) without changing your app code.
- Example (Redis):
- nitro.storage.data = { driver: 'redis', /* connection */ }
- Domains:
- Map real domains (e.g., acme.example.com) to your deployment.
- The tenant resolver already uses subdomains, but you can extend it to map custom hostnames to tenant IDs from storage.
- Security:
- The simple token header is for demo purposes. In real apps, add proper auth, role checks, and validation.
Conclusion
You now have a multi‑tenant landing page builder powered by Nuxt 4, TypeScript, and Tailwind:
- Tenants resolved from subdomains or query params
- Per‑tenant theme via CSS variables and Tailwind
- JSON‑driven block rendering with a simple admin editor
- Fast public pages with Nuxt 4 route rules and ISR
- Portable storage powered by Nitro’s unstorage
From here, you can add more block types, versioning, real authentication, media uploads, and deploy with shared storage for true multi‑tenant scale.





