Configuration
Develi’s configuration is four TypeScript modules under src/config/, each typed by an interface in src/config/types/configDataTypes.ts. They are TypeScript rather than JSON for one reason: a mistake becomes a build error naming the offending value, instead of a blank space on a page.
Nothing in a component hard-codes a brand string, a route, a phone number or a feature flag. It imports one of these four.
siteData.json.ts — metadata and branding
const siteData: SiteDataProps = {
name: "Develi",
title: "Develi — Astro 7 + Tailwind v4 starter",
description: "…",
author: {
name: "Your Name",
email: "[email protected]",
twitter: "yourhandle", // no @ — BaseHead adds it
},
defaultImage: { src: "/og.jpg", alt: "Develi" },
contact: {
email: "[email protected]",
phone: "+1 (000) 000-0000",
},
sameAs: navData.social.map((s) => s.href),
};
Three things here are less obvious than they look.
author and contact are different things and both exist on purpose. author attributes articles — it feeds twitter:creator and the JSON-LD author node on a blog post. contact is the address and number you want people to write to, and the footer renders both. Conflating them is the usual mistake; keeping them apart means your byline can be a person while your contact address is the studio.
defaultImage is the fallback OG image, and its dimensions matter. BaseHead emits real og:image:width and og:image:height from the bundled ImageMetadata when a page passes its own image, and falls back to the 1200×630 convention otherwise. If you replace public/og.jpg with an image of different dimensions, the fallback tags will be lying. Keep it 1200×630.
sameAs is derived, not typed. It maps navData.social, so the links in your footer and the profiles your structured data claims are one list. Edit them in navData.json.ts.
siteSettings.json.ts — locale and the two switches
export const siteLang = "en" as const;
export const siteLocale = "en-US" as const;
export const siteSettings = {
useViewTransitions: true,
useAnimations: true,
} satisfies SiteSettingsProps;
siteLang becomes <html lang>. siteLocale is the BCP-47 tag used for Intl date formatting, og:locale (converted to en_US — Open Graph wants an underscore) and JSON-LD inLanguage. These two constants are the only locale facts in the theme. The i18n layer was removed deliberately; see Pages & Routing for what re-adding it would involve.
The satisfies operator rather than a type annotation is load-bearing. It checks the shape while preserving the literal true, where an annotation would widen it to boolean. The same trick is what makes navData’s derived unions possible.
useAnimations is a design switch, not an accessibility one. It gates the decorative motion layer — scroll reveals, ambient loops — at build time. prefers-reduced-motion is honored by a global CSS guard regardless of what this flag says, and turning useAnimations off is never a substitute for it. Intentional micro-interactions (a chevron rotating on a dropdown) are not gated on it either; those are UX, not decoration. Motion covers the distinction.
useViewTransitions mounts Astro’s <ClientRouter /> in BaseHead. Turn it off and navigation becomes ordinary full page loads.
navData.json.ts — the information architecture
This is the largest config file and the one to read before editing. It owns the header links, the header CTA, the six services, the footer columns, the two legal routes and the social profiles — and several of those are derived from one another rather than typed twice.
The services array
const services = [
{ slug: "marketing-sites", label: "Marketing sites" },
{ slug: "product-engineering", label: "Product engineering" },
{ slug: "ai-native-features", label: "AI-native features" },
{ slug: "security", label: "Security" },
{ slug: "motion-webgl", label: "Motion & WebGL" },
{ slug: "design-systems", label: "Design systems" },
] as const;
It lives in config rather than in the services page because two surfaces read it: the /services/ tab rail and the footer’s Services column. The footer column is derived from it:
links: services.map(({ slug, label }) => ({ label, href: `/services/${slug}/` })),
That derivation exists because the two had already disagreed — the footer named four services the services page does not offer, pointing at routes no tab claimed. Because ServiceSlug is derived from the array’s value ((typeof services)[number]["slug"]), renaming a service is now an astro check error at every call site rather than a stale literal sitting quietly in the footer.
headerHref — resolving a route by label
export function headerHref(label: HeaderLabel): string {
const link = navData.headerLinks.find((item) => item.label === label);
if (!link) throw new Error(`navData.headerLinks has no "${label}" row`);
return link.href;
}
Sections link into the IA constantly — the hero’s “Explore work”, the case-studies rail’s “View all cases”. They call headerHref("Work") rather than writing /work/. It is keyed on the label, never the array index, because the order of headerLinks is presentation and reordering the header must not silently repoint a section.
It throws rather than falling back. A ?? "/work/" fallback would let a stale literal serve traffic while the header and the sections had already drifted apart, and a route that no longer exists should fail the build.
It lives in this file rather than in @js/nav because nav.ts must stay dependency-free for its self-check to run under bare Node.
Legal links are keyed, not listed
legalLinks: {
privacy: { label: "Privacy", href: "/privacy/" },
terms: { label: "Terms", href: "/terms/" },
},
Keyed rather than an array because the contact form links the privacy page specifically, and finding it in a list by matching its label would reintroduce the drift by the back door.
The eight rows that 404
navData ships the design’s complete IA so the chrome looks finished out of the box. Eight of those targets have no route: /careers/, /process/, and the six /services/<slug>/ detail pages. The file says so in a comment, and the theme’s deploy checklist says so again. Build them or delete the rows before launch — until you do, every page on the site links to eight dead URLs. This is covered as step 6 in Deployment.
Trailing slashes are not optional
astro.config.mjs sets trailingSlash: "always". Every href in this file carries one, and a row written without one will not match the built route. The single exception is /rss.xml, which is a file rather than a directory route.
legalData.json.ts — terms and privacy copy
A Record<"terms" | "privacy", LegalPageProps>, where each document is a title, a meta description, an ISO lastUpdated date, an intro and an array of { heading, body[] } sections. Each string in body renders as its own <p>.
The shipped copy is placeholder template text and says so in its own intro paragraph. Replace it with your own, reviewed by a qualified legal professional. It is not legal advice, and shipping it as-is means publishing a document that tells your visitors it is a placeholder.
lastUpdated is formatted at render through formatDate from @js/textUtils, so it follows siteLocale.
The typing pattern worth copying
Every one of these files pairs a value with an interface, and the interfaces live together in types/configDataTypes.ts. When you add a fifth config file — a pricing table, an FAQ — add its interface there too.
Two idioms recur and are worth adopting in your own additions:
satisfies over an annotation when you want the literal types preserved. That is what lets HeaderLabel and ServiceSlug be derived from the values rather than hand-written as parallel unions that can drift.
A typed union derived from the data, not written beside it:
export type ServiceSlug = (typeof services)[number]["slug"];
ServiceTabs keys its panel copy by ServiceSlug, so dropping a service row fails astro check at that map rather than rendering a rail with a missing panel.