DEV Community

Pankaj Singh
Pankaj Singh

Posted on

One config file, zero runtime JS: how I built a fast Astro + Tailwind 4 template for small business sites

Most small business websites I get asked about have the same shape: a home page, an about page, a list of products or services, a page for each product, and a contact page with a phone number, a WhatsApp button and a map. The owner wants it fast, wants it to show up on Google, and wants to change the phone number without calling a developer.

I'm a backend .NET developer by day, so my instinct was to treat this like a data problem. The pages are just views over one object. In this post I'll walk through how I set that up with Astro 7 and Tailwind CSS 4, using real code from the template I built. You can reuse every pattern here in your own project.

1. Put all the content in one typed file

Everything a visitor sees lives in src/data/site.ts: name, colours, contact details, SEO text, products, testimonials and navigation. Pages and components only read from it.

// src/data/site.ts
export interface Product {
  /** URL slug: /products/<slug>. Lowercase, hyphens only. */
  slug: string;
  name: string;
  category: string;
  summary: string;
  description: string[];
  image: string;
  price?: string;
  specs?: Record<string, string>;
  features?: string[];
  featured?: boolean;
}

export const site = {
  name: 'Sharma Precision Tools',
  shortName: 'SPT',
  url: 'https://www.example.com',
  theme: {
    primary: '#1d4ed8',
    primaryDark: '#1e3a8a',
    accent: '#f59e0b',
    dark: '#0f172a',
  },
  contact: {
    phone: '+91 98000 00000',
    whatsapp: '919800000000',
    // ...
  },
  products: [
    {
      slug: 'circular-slitting-knives',
      name: 'Circular Slitting Knives',
      category: 'Blades',
      // ...
    },
  ] satisfies Product[],
  // seo, home, about, nav, testimonials...
};
Enter fullscreen mode Exit fullscreen mode

Two small TypeScript details make this pleasant:

  • satisfies Product[] checks every product against the interface but keeps the literal types. A typo in a field name fails the build instead of failing silently in production.
  • Optional fields (price?, specs?) let one product have a spec table and another have none. The components just check whether the field exists.

Why not a CMS or Astro content collections? For a 6-page site that the owner edits a few times a year, one file is easier to explain than a dashboard. If the site grows, moving products into a content collection is a small refactor because the shape is already typed.

2. Generate a page per product with getStaticPaths

The product detail route is a single file, src/pages/products/[slug].astro. Astro calls getStaticPaths at build time and writes one HTML file per product:

---
import Base from '../../layouts/Base.astro';
import { site, whatsappLink, type Product } from '../../data/site';

export function getStaticPaths() {
  return site.products.map((product) => ({
    params: { slug: product.slug },
    props: { product },
  }));
}

const { product } = Astro.props as { product: Product };
const related = site.products
  .filter((p) => p.slug !== product.slug && p.category === product.category)
  .slice(0, 3);
const ask = whatsappLink(`Hello! I'd like a quote for: ${product.name}`);
---
Enter fullscreen mode Exit fullscreen mode

Add an object to the array and you get a new page, a new sitemap entry and a "related products" block. Nobody touches routing.

The WhatsApp link is a tiny helper in the same data file. In India, WhatsApp is often the main way customers contact a business, so it matters more than the contact form:

export function whatsappLink(text: string = site.contact.whatsappMessage): string {
  return `https://wa.me/${site.contact.whatsapp}?text=${encodeURIComponent(text)}`;
}

export function telLink(): string {
  return `tel:${site.contact.phone.replace(/[^\d+]/g, '')}`;
}
Enter fullscreen mode Exit fullscreen mode

The phone is stored once in a human-friendly format (+91 98000 00000), and telLink() strips it down for the tel: link.

3. Brand colours as CSS variables, exposed to Tailwind 4

I wanted owners to change colours in site.ts, not in CSS. Tailwind 4's @theme inline makes this clean. The layout writes the colours onto <html> as CSS variables:

---
// src/layouts/Base.astro
const t = site.theme;
const brandVars = `--brand-primary:${t.primary};--brand-primary-dark:${t.primaryDark};--brand-accent:${t.accent};--brand-dark:${t.dark};`;
---
<html lang={site.lang} style={brandVars}>
Enter fullscreen mode Exit fullscreen mode

The stylesheet then maps those variables to Tailwind theme colours:

/* src/styles/global.css */
@import "tailwindcss";

@theme inline {
  --color-primary: var(--brand-primary);
  --color-primary-dark: var(--brand-primary-dark);
  --color-accent: var(--brand-accent);
  --color-dark: var(--brand-dark);
}
Enter fullscreen mode Exit fullscreen mode

Now bg-primary, text-primary-dark and hover:bg-primary-dark all work as normal utilities, and they follow whatever hex value is in the config. inline matters here: it tells Tailwind to use the var(...) reference directly in the generated utility instead of resolving it to a fixed value at build time.

Tailwind 4 also needs no tailwind.config.js. The whole Astro config is this:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import { site } from './src/data/site.ts';

export default defineConfig({
  site: process.env.SITE_URL || site.url,
  compressHTML: true,
  integrations: [sitemap()],
  vite: { plugins: [tailwindcss()] },
});
Enter fullscreen mode Exit fullscreen mode

The SITE_URL override is handy for demo or staging deploys, so canonical links and the sitemap point at the right domain without editing the config.

4. Ship (almost) no JavaScript

Astro ships zero JS by default, and the easiest way to keep it fast is to not undo that. Two places where people usually reach for JS:

The mobile menu. A <details> element opens and closes without a single line of script, and it's keyboard accessible out of the box:

<details class="relative md:hidden">
  <summary class="grid h-10 w-10 cursor-pointer list-none place-items-center rounded-lg border border-slate-300 [&::-webkit-details-marker]:hidden" aria-label="Open menu">
    <!-- hamburger svg -->
  </summary>
  <ul class="absolute right-0 mt-2 w-56 rounded-xl border border-slate-200 bg-white p-2 shadow-lg">
    {site.nav.map((item) => (
      <li><a href={item.href} class="block rounded-lg px-3 py-2">{item.label}</a></li>
    ))}
  </ul>
</details>
Enter fullscreen mode Exit fullscreen mode

The contact form. The form has three modes set in config: netlify (Netlify Forms), action (post to Formspree or your own API) and whatsapp. Only the WhatsApp mode needs a script, and it's about 15 lines. It reads FormData, joins the fields into a message and opens wa.me. That's the only client-side JS on the whole site, and it only loads on the contact page.

Other things that helped performance:

  • System font stack. No web font download. Inter is first in the stack, so it's used if the visitor already has it.
  • Fixed image dimensions. Every <img> has width and height, plus loading="lazy" and decoding="async" on cards. That prevents layout shift.
  • Reduced motion. A prefers-reduced-motion block turns off transitions and smooth scrolling for people who ask for it.

When I ran Lighthouse on the home, product and contact pages of the demo, all four categories scored 100. Your numbers will depend on your images and hosting, so test your own build.

5. SEO that comes from the same config

Base.astro builds the canonical URL, Open Graph and Twitter tags from site.seo and the current path. It also adds LocalBusiness JSON-LD on every page:

const canonical = new URL(Astro.url.pathname, Astro.site ?? site.url).href;

const businessLd = {
  '@context': 'https://schema.org',
  '@type': 'LocalBusiness',
  name: site.name,
  url: site.url,
  telephone: site.contact.phone,
  address: site.contact.address,
  sameAs: Object.values(site.social).filter(Boolean),
};
Enter fullscreen mode Exit fullscreen mode
<script type="application/ld+json" set:html={JSON.stringify(businessLd)} />
Enter fullscreen mode Exit fullscreen mode

Product pages pass an extra Product schema through a jsonLd prop. @astrojs/sitemap writes sitemap-index.xml, and robots.txt is a tiny endpoint, so it always points at the right domain:

// src/pages/robots.txt.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = ({ site }) => {
  const sitemap = new URL('sitemap-index.xml', site).href;
  return new Response(`User-agent: *\nAllow: /\n\nSitemap: ${sitemap}\n`, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
};
Enter fullscreen mode Exit fullscreen mode

The sameAs filter is a small win. Empty social links in the config disappear from both the footer and the structured data.

6. Deploy for free

npm run build outputs a plain static dist/ folder, so any static host works:

  • Netlify: a netlify.toml sets npm run build, dist and Node 22. Netlify Drop (drag the dist folder into the browser) works if you don't use Git.
  • Vercel: import the repo. It detects Astro automatically.
  • Cloudflare Pages: pick the Astro preset and set NODE_VERSION to 22.

Astro 7 needs Node 22.22 or newer, so set that on your host or the build will fail early. An .nvmrc file and the engines field in package.json keep local machines and CI in line.

Wrapping up

The core idea is simple: one typed data file, pages that are pure views over it, and no JavaScript unless a feature truly needs it. That split makes the site easy for a non-developer to edit and hard for it to get slow over time.

If you want to see it running, the demo is at astro-business-template-demo.vercel.app. I've also packaged the full template with all six pages, the three form modes and deploy docs, on my Payhip store, in case you'd rather not build it from scratch.

Questions or a better pattern for any of this? I'd like to hear it in the comments.


Parts of this post were drafted with AI assistance and reviewed by me.

Top comments (0)