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.
Data modes
The entire fetch layer lives in src/lib/backend.ts. It reads one environment variable to decide where data comes from:
# 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/v1When 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
// 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
| Variable | Purpose | Default |
|---|---|---|
| PUBLIC_USE_DUMMY | Use local JSON fixtures instead of the API | "true" |
| PUBLIC_API_BASE_URL | Root URL of the WebFactor REST API | — |
| PUBLIC_SITE_URL | Canonical site URL for SEO + Astro site | — |
| CMS_BASE_URL | Local CMS server for image optimisation during dev | http://localhost:4321 |
| PUBLIC_ASSETS_BASE_URL | CDN / 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
- Fetch the sitemap — calls
fetchSitemap("page")andfetchSitemap("post")in parallel to get every slug that should exist. - Fetch languages — calls
fetchLanguages()to know which locales are active, what theirurl_prefixis (e.g./en,/ru), and which is the default (no prefix). - 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. - Return
getStaticPaths()— Astro generates one static HTML file per path. At request time,fetchContent()pulls the page data and passes it toWfBlocks.
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.
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
{
"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.
{
"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
- Register the slug in the sitemap. Open
src/assets/dummy/sitemap.jsonand add an entry:Add one entry per locale you want to support.{ "type_key": "page", "slug": "about", "locale": "bg", "slug_prefix": "" } - 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" } } ] } - 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
- 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> - 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 }; - 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().
- 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" } ] } - Register it in the
dummyCollectionsmap insidesrc/lib/backend.ts:import teamCollection from "@/assets/dummy/collections/team.json"; const dummyCollections = { gallery: galleryCollection, team: teamCollection, // ← add this }; - Fetch it in any page or block:In API mode, this maps to
const { items, total } = await fetchByType("team", { limit: "10" }); // items → [{ id, name, role }, ...]GET /content?type=team&limit=10automatically.
Go to production
- 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 - Build.
npm run buildcallsgetStaticPaths()which hits the live API to get the full sitemap, then fetches every page to generate static HTML. Output goes to../public_html(configured inastro.config.mjs). - Partial builds — set
ASTRO_ONLY_ROUTESto 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.
---
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 *;.
// 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
- Create
src/styles/main/_wf_mybanner.scsswith your BEM styles. - Import it at the top of your block file:
--- import "@/styles/main/_wf_mybanner.scss"; --- - 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
<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.
<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
| Prop | Type | Default | Notes |
|---|---|---|---|
| src | string | ImageMetadata | — | Relative paths get the CMS/assets prefix prepended |
| alt | string | — | Required. Empty string for decorative images |
| width | number | — | Base intrinsic width in px |
| height | number | — | Base intrinsic height in px |
| responsiveWidths | Array | [] | Generates <picture> with per-breakpoint sources |
| lazy | boolean | true | Uses 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 |
| quality | number | 80 | WebP compression quality 1–100 |
| fit | "cover" | "contain" | ... | "contain" | Sharp resize fit mode |
| class | string | — | CSS 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-src → src (and data-srcset → srcset 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
---
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.
.social-btn {
color: #fff; // icon inherits this as fill/stroke
svg {
width: 2rem;
height: 2rem;
}
&:hover {
color: #5ce0d8; // colour changes on hover automatically
}
}<Icon name="mail" class="icon-md" />
<style>
.icon-md { width: 2.4rem; height: 2.4rem; }
</style>Adding a new icon
- Copy a clean SVG into
src/icons/my-icon.svg. Make sure it usescurrentColorfor 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> - 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
---
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