WebFactor Frontend Template

A production-ready Astro starter that connects to the WebFactor headless CMS. All routing, SEO, i18n, and page sections are driven by the API — but during local development you never need a live server. A single .env flag swaps between local JSON fixtures and the real API.

.env
Toggle dummy vs API
backend.ts
Unified fetch layer
[...slug].astro
All pages, one route
WfBlocks
Sections → components

Data modes

The entire fetch layer lives in src/lib/backend.ts. It reads one environment variable to decide where data comes from:

.env dotenv
# Local JSON fixtures — no API needed
PUBLIC_USE_DUMMY=true

# Live WebFactor API
PUBLIC_USE_DUMMY=false
PUBLIC_API_BASE_URL=https://yoursite.com/api/v1

When PUBLIC_USE_DUMMY=true, every fetch*() call in backend.ts reads from files under src/assets/dummy/ instead of hitting the network. The shapes are identical — switching modes requires no code changes.

Dummy mode is the default. The repo ships with PUBLIC_USE_DUMMY=true so you can npm run dev immediately with zero configuration.

What each function returns

src/lib/backend.ts ts
// Sitemap — all slugs for a given type ("page" | "post" | ...)
fetchSitemap(type)        → Promise<SitemapItem[]>

// Languages — default locale + all active locales with url_prefix
fetchLanguages()          → Promise<{ default: string, items: Locale[] }>

// Single page/post by type + slug + locale
fetchContent(type, slug, locale) → Promise<PageContent>

// Paginated list for a content type (gallery, products, etc.)
fetchByType(type, params) → Promise<{ items: any[], total: number }>

Environment variables

VariablePurposeDefault
PUBLIC_USE_DUMMYUse local JSON fixtures instead of the API"true"
PUBLIC_API_BASE_URLRoot URL of the WebFactor REST API
PUBLIC_SITE_URLCanonical site URL for SEO + Astro site
CMS_BASE_URLLocal CMS server for image optimisation during devhttp://localhost:4321
PUBLIC_ASSETS_BASE_URLCDN / media server for production images

Note: Variables prefixed PUBLIC_ are exposed to the client bundle. Keep any secret tokens in non-public variables and access them only in server-side Astro frontmatter.

Routing

There is exactly one dynamic route: src/pages/[...slug].astro. It handles every URL on the site — homepage, pages, blog posts, locale variants — all of it.

How it builds routes at compile time

  1. Fetch the sitemap — calls fetchSitemap("page") and fetchSitemap("post") in parallel to get every slug that should exist.
  2. Fetch languages — calls fetchLanguages() to know which locales are active, what their url_prefix is (e.g. /en, /ru), and which is the default (no prefix).
  3. Build localeUrls — for every page/post, it builds a map of { locale: '/path/' } pairs. This is what powers the language switcher — each locale gets a direct link to the translated version of the current page.
  4. Return getStaticPaths() — Astro generates one static HTML file per path. At request time, fetchContent() pulls the page data and passes it to WfBlocks.
URL examples from dummy sitemap text
slug: "index"  + locale: "bg" (default)  →  /
slug: "index"  + locale: "en"            →  /en/
slug: "about"  + locale: "bg"            →  /about/
slug: "about"  + locale: "ru"            →  /ru/about/

Blocks system

Every page in the CMS is a list of sections. Each section has a section_key (a string identifier) and a data object (arbitrary props the block component needs).

src/components/WfBlocks.astro is the resolver. It maps section_key strings to Astro components and renders them in order.

src/components/WfBlocks.astro — the registry astro
export const components: Record<string, any> = {
  wfexample: WfExample,   // ← maps "wfexample" key → component
  wfhero:    WfHero,
  // add yours here
};

// Renders each section in order, spreading data as props:
sections
  .sort((a, b) => a.position - b.position)
  .map((section) => {
    const Component = components[section.section_key];
    if (!Component || section.data?.hide) return null;
    return <Component {...section.data} />;
  })

The data object from the CMS is spread directly as props onto the component — so whatever fields the CMS sends, the component receives as Astro.props.

hide flag: Any section with data.hide === true is silently skipped. The CMS can toggle sections off without removing them.

Dummy page content shape

src/assets/data/index.json — example page json
{
  "locale": "bg",
  "seo_effective": {
    "meta_title": "Page Title",
    "meta_description": "...",
    "robots": "index,follow"
  },
  "sections": [
    { "section_key": "wfhero",    "position": 10, "data": { "title": "Hello" } },
    { "section_key": "wfexample", "position": 20, "data": {} }
  ]
}

i18n & locales

Language configuration lives in src/assets/dummy/languages.json (dummy mode) or is returned by GET /languages (API mode). The template supports any number of locales — one is marked as default and gets no URL prefix.

src/assets/dummy/languages.json json
{
  "default": "bg",
  "items": [
    { "code": "bg", "url_prefix": "",    "is_default": true  },
    { "code": "en", "url_prefix": "/en", "is_default": false },
    { "code": "ru", "url_prefix": "/ru", "is_default": false }
  ]
}

The WfLang component (src/components/WfLang.astro) receives the built locales array — each entry is { code, slug } where slug is the translated URL for the current page. It renders as a fixed right-edge tab with a dropdown. Optionally pass flag and label fields for richer display.

Footer i18n: src/helpers/i18n.ts exports getWebfactorLabel(locale) which returns the localised "Web Development by" string in 50+ languages — used in the footer attribution.

Add a page dummy mode

  1. Register the slug in the sitemap. Open src/assets/dummy/sitemap.json and add an entry:
    {
      "type_key": "page",
      "slug": "about",
      "locale": "bg",
      "slug_prefix": ""
    }
    Add one entry per locale you want to support.
  2. Create the content file. Add src/assets/data/about.json — the filename must match the slug:
    {
      "locale": "bg",
      "seo_effective": {
        "meta_title": "About us",
        "robots": "index,follow"
      },
      "sections": [
        { "section_key": "wfhero", "position": 10, "data": { "title": "About" } }
      ]
    }
  3. Visit the page. Restart the dev server if it was already running, then open http://localhost:4321/about/.

How the data file is found: backend.ts uses import.meta.glob("../assets/data/*.json") to auto-import every JSON at startup. The slug is derived from the filename — no manual registration needed.

Add a block

  1. Create the component in src/blocks/:
    src/blocks/WfBanner.astro astro
    ---
    interface Props {
      title?: string;
      subtitle?: string;
    }
    const { title = "Default title", subtitle } = Astro.props;
    ---
    
    <section class="wf-banner">
      <h2>{title}</h2>
      {subtitle && <p>{subtitle}</p>}
    </section>
  2. Register it in WfBlocks (src/components/WfBlocks.astro):
    import WfBanner from "@/blocks/WfBanner.astro";
    
    export const components: Record<string, any> = {
      wfexample: WfExample,
      wfhero:    WfHero,
      wfbanner:  WfBanner,   // ← add this line
    };
  3. Use it in a page JSON by adding a section with the matching key:
    {
      "section_key": "wfbanner",
      "position": 30,
      "data": {
        "title": "Special offer",
        "subtitle": "Book before Friday"
      }
    }

Add a collection

Collections are lists of items (gallery, team members, products, etc.). They use fetchByType() instead of fetchContent().

  1. Create the fixture at src/assets/dummy/collections/team.json:
    {
      "total": 2,
      "items": [
        { "id": 1, "name": "Alice", "role": "Designer" },
        { "id": 2, "name": "Bob",   "role": "Developer" }
      ]
    }
  2. Register it in the dummyCollections map inside src/lib/backend.ts:
    import teamCollection from "@/assets/dummy/collections/team.json";
    
    const dummyCollections = {
      gallery: galleryCollection,
      team:    teamCollection,   // ← add this
    };
  3. Fetch it in any page or block:
    const { items, total } = await fetchByType("team", { limit: "10" });
    // items → [{ id, name, role }, ...]
    In API mode, this maps to GET /content?type=team&limit=10 automatically.

Go to production

  1. Switch off dummy mode. In your production .env:
    PUBLIC_USE_DUMMY=false
    PUBLIC_API_BASE_URL=https://yoursite.com/api/v1
    PUBLIC_SITE_URL=https://yoursite.com
    PUBLIC_ASSETS_BASE_URL=https://yoursite.com
  2. Build. npm run build calls getStaticPaths() which hits the live API to get the full sitemap, then fetches every page to generate static HTML. Output goes to ../public_html (configured in astro.config.mjs).
  3. Partial builds — set ASTRO_ONLY_ROUTES to build only specific slugs. Useful for rebuilding a single changed page without a full site rebuild:
    ASTRO_ONLY_ROUTES=about,contact npm run build

Assets: All JS/CSS is inlined and bundled into _webfactor/. Images processed through Astro's image pipeline are converted to WebP. SVGs are optimised via astro-compress.

Styles architecture

Styles are written in SCSS and compiled by LightningCSS (configured in astro.config.mjs). The entire stylesheet is inlined into the HTML at build time — no separate CSS file is shipped to the browser.

The rule: styles live next to their component

Every block and component that needs styles has a dedicated .scss file in src/styles/main/. That file is imported directly inside the component file — not from a central index.

src/components/WfCookies.astro — import at the top astro
---
import "@/styles/main/_wf_cookies.scss";   // ← co-located styles
// ... rest of frontmatter
---

<div class="wf_cookies"> ... </div>

This means you can find and delete a block's CSS by looking at the single import at the top of its .astro file — no hunting through a global stylesheet.

Directory layout

src/styles/
├── globals.scss          ← entry point — @use-s everything below   also defines CSS custom properties (:root)
│
├── base/
│   ├── _reset.scss         ← box-sizing, margin resets
│   ├── _mixins.scss        ← min(), max(), truncate(), reveal(), etc.
│   └── _helpers.scss       ← utility classes
│
├── main/               ← one file per block/component
│   ├── _wf_h.scss          ← Header styles
│   ├── _wf_f.scss          ← Footer styles
│   ├── _wf_hero.scss       ← WfHero block
│   ├── _wf_cookies.scss    ← WfCookies block
│   ├── _buttons.scss       ← shared button styles
│   └── ...
│
├── animations/
│   └── _main.scss          ← keyframes & entrance animations
│
├── plugins/
│   ├── _keen-slider.scss   ← slider overrides
│   └── _lenis.scss         ← smooth scroll overrides
│
└── inline.scss             ← critical CSS inlined in <head> (above the fold)

Available mixins

Any SCSS file can access the mixins with @use "../base/mixins" as *;.

src/styles/base/_mixins.scss — quick reference scss
// Responsive breakpoints
@include min(768px) { ... }   // (min-width: 768px)
@include max(600px) { ... }   // (max-width: 600px)

// Line clamp
@include truncate(3);         // -webkit-line-clamp: 3

// Entrance animation (opacity 0 → 1 + translateY)
@include reveal($delay: 0.2s, $duration: 1.2s);

// Custom scrollbar
@include customScroll("y", #d8d8d8, #5ce0d8);

Adding styles to a new block

  1. Create src/styles/main/_wf_mybanner.scss with your BEM styles.
  2. Import it at the top of your block file:
    ---
    import "@/styles/main/_wf_mybanner.scss";
    ---
  3. That's it. Astro deduplicates imports — if two components import the same file it's only included once.

Blocks with self-contained styles (like WfExample.astro and WfLang.astro) use a <style> block inside the .astro file instead of a separate SCSS file. Astro automatically scopes these or keeps them unscoped depending on the selector — both approaches work.

WfImage

src/components/WfImage.astro is the single component to use for every image in the project. It wraps Astro's getImage() to handle relative CMS paths, output WebP, generate responsive <picture> sources, and lazy-load with a data-src swap pattern.

Basic usage

simple image astro
<WfImage
  src="/uploads/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
/>

A relative src (no http) is automatically prefixed with CMS_BASE_URL in dev or PUBLIC_ASSETS_BASE_URL in production. The image is converted to WebP at the specified dimensions.

Responsive widths

Pass responsiveWidths to generate a <picture> element with multiple <source> tags — one per breakpoint. The component calculates height proportionally from your base width / height.

responsive picture astro
<WfImage
  src="/uploads/team-photo.jpg"
  alt="Our team"
  width={1600}
  height={900}
  responsiveWidths={[
    { width: 400,  max: 600  },   // mobile  → 400px wide image
    { width: 800,  min: 601, max: 1024 },   // tablet
    { width: 1600, min: 1025 },   // desktop → full res
  ]}
/>

All props

PropTypeDefaultNotes
srcstring | ImageMetadataRelative paths get the CMS/assets prefix prepended
altstringRequired. Empty string for decorative images
widthnumberBase intrinsic width in px
heightnumberBase intrinsic height in px
responsiveWidthsArray[]Generates <picture> with per-breakpoint sources
lazybooleantrueUses data-src swap; set false for LCP images
loading"lazy" | "eager""lazy"Native browser hint (separate from the JS lazy swap)
fetchpriority"high" | "low" | "auto"Set "high" on hero / above-fold images
qualitynumber80WebP compression quality 1–100
fit"cover" | "contain" | ..."contain"Sharp resize fit mode
classstringCSS class forwarded to the <img> or <figure>

LCP images: For the first visible image (hero, above-fold), always set lazy= and fetchpriority="high". The data-src swap delays the load and hurts Core Web Vitals if used on critical images.

Lazy loading — how it works

When lazy=true (the default), WfImage renders src="" and data-src="...". The global src/assets/js/webfactor_main.js runs an IntersectionObserver that copies data-srcsrc (and data-srcsetsrcset on sources) when the image enters the viewport. A <noscript> fallback with real src attributes is always rendered for no-JS environments.

Icons

Icons are handled by astro-icon (astro-icon@^1.1.5). The project uses local SVG files — drop an SVG into src/icons/ and it's immediately available by filename as an inline SVG. No sprite sheets, no external requests.

Basic usage

any .astro file astro
---
import { Icon } from "astro-icon/components";
---

<Icon name="mail" />
<Icon name="fb" />
<Icon name="chevron" />

The name prop matches the SVG filename inside src/icons/ without the extension. name="mail" renders src/icons/mail.svg inline into the HTML.

Icons in this project

src/icons/
├── fb.svg          ← Facebook
├── x.svg           ← X / Twitter
├── linkedin.svg
├── messenger.svg
├── whatsapp.svg
├── viber.svg
├── mail.svg
├── call.svg
├── googlemaps.svg
├── waze.svg
├── applemaps.svg
├── yt.svg
├── chevron.svg
├── play.svg
├── pause.svg
└── wf.svg           ← WebFactor logomark

Styling icons

Because the SVG is inlined, it inherits color from its parent via currentColor. Size and colour are set entirely with CSS — no inline attributes needed.

SCSS scss
.social-btn {
  color: #fff;          // icon inherits this as fill/stroke

  svg {
    width: 2rem;
    height: 2rem;
  }

  &:hover {
    color: #5ce0d8;     // colour changes on hover automatically
  }
}
size via class prop astro
<Icon name="mail" class="icon-md" />

<style>
  .icon-md { width: 2.4rem; height: 2.4rem; }
</style>

Adding a new icon

  1. Copy a clean SVG into src/icons/my-icon.svg. Make sure it uses currentColor for fill/stroke so it responds to CSS:
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
      <path fill="currentColor" d="..." />
    </svg>
  2. Use it immediately — no import or registration step:
    <Icon name="my-icon" />

Iconify sets: astro-icon also supports the full Iconify catalog (100k+ icons). Install the set package and use a namespaced name — name="mdi:home", name="heroicons:star". Local src/icons/ files always take precedence over any set icon with the same name.

Real usage — share bar in ArticleLayout

src/layouts/ArticleLayout.astro (excerpt) astro
---
import { Icon } from "astro-icon/components";
---

<a class="wf_share__btn" href={fbUrl} target="_blank">
  <Icon name="fb" />
</a>
<a class="wf_share__btn" href={xUrl} target="_blank">
  <Icon name="x" />
</a>
<a class="wf_share__btn" href={`mailto:?body=${url}`}>
  <Icon name="mail" />
</a>

<!-- Two icons in one button — toggled with CSS -->
<button class="wf_share__copy" type="button">
  <Icon name="link"  class="wf_share__icon-link" />
  <Icon name="check" class="wf_share__icon-check" />
</button>

File structure

src/
├── assets/
│   ├── data/           ← one JSON per page slug (dummy content)
│   │   └── index.json
│   └── dummy/
│       ├── sitemap.json      ← all slugs the router should generate
│       ├── languages.json    ← active locales + url_prefix
│       └── collections/      ← list data (gallery, team, etc.)
│
├── blocks/           ← one component per section_key
│   ├── WfExample.astro
│   └── WfHero.astro
│
├── components/
│   ├── Header.astro
│   ├── Footer.astro
│   ├── WfBlocks.astro    ← section_key → component registry
│   ├── WfImage.astro     ← always use this for images
│   └── WfLang.astro      ← language switcher (fixed, right edge)
│
├── helpers/
│   ├── i18n.ts           ← getWebfactorLabel(locale)
│   └── utils.ts          ← getOptimizedImage, slugify, formatDate
│
├── layouts/
│   └── Layout.astro      ← HTML shell, SEO, Header, Footer
│
├── lib/
│   └── backend.ts        ← ALL data fetching lives here
│
├── pages/
│   ├── [...slug].astro   ← the only dynamic route
│   └── docs.astro        ← you are here
│
└── styles/
    ├── globals.scss       ← root entry, CSS vars, @use-s all partials
    ├── base/              ← reset, mixins, helpers
    ├── main/              ← _wf_xxx.scss per block/component
    ├── animations/        ← keyframes
    └── plugins/           ← third-party overrides