DEV Community

Cover image for Built a Schema.org Generator for a Country of 93k People
Eduardo Lázaro
Eduardo Lázaro

Posted on

Built a Schema.org Generator for a Country of 93k People

Andorra is a microstate between Spain and France with around 93k inhabitants, so most of the companies are small local businesses.

After checking many websites, I noticed the same gap everywhere: zero structured data. No Schema.org markup at all. Google was guessing what these businesses were, where they were, and what they offered. And Google was guessing wrong.

So this weekend I built this Schema.org LocalBusiness generator tailored specifically for the Andorran market. It'sopen to anyone, and it solves problems that generic schema generators don't even know exist

Generic generators don't work for Andorra

Andorra has some quirks that trip up every general-purpose Schema.org tool:

  • Postal codes nobody recognizes: Andorran postal codes go from AD100 (Canillo) to AD700 (Andorra la Vella). Most generators expect 5-digit codes or don't recognize "AD" as a valid country prefix.
  • Parishes instead of cities: Andorra is divided into 7 parishes (parroquies), not cities. When you select "Andorra la Vella" it's both the capital and a parish. Generic tools don't have this context, so addressLocality and addressRegion end up wrong or empty.
  • Three languages by default: A typical local business in Andorra serves customers in Catalan, Spanish, and French. The availableLanguage field matters here more than in most markets, because tourists from Spain and France are your primary audience.
  • NRT (tax ID): Andorra has its own fiscal identifier, the Numero de Registre Tributari. Generic generators don't have an identifier field for this, but it's useful structured data for B2B businesses.
  • Country code confusion: You'd be surprised how many Andorran business websites have addressCountry: "ES" or "FR" in their schema. Because whoever built the site in Barcelona just used Spain's code by default.

The implementation

The tool is built with Alpine.js. No backend processing needed: the JSON-LD is generated entirely client-side.

*btw I know alpine is not as popular as other tools but does the job

Architecture

Alpine.js component: schemaGenerator()
  ├── Reactive form state (defaultState())
  ├── Computed output (get output())
  ├── Geocoding (Nominatim/OpenStreetMap API)
  ├── Copy to clipboard
  └── Download as .json
Enter fullscreen mode Exit fullscreen mode

The form state holds every field. The output getter is a computed property that builds the JSON-LD object reactively as the user fills in the form.

There's no submit button, no roundtrip to the server. You type and you see the JSON update in real time.

The form state

const defaultState = () => ({
    type: 'LocalBusiness',
    name: '',
    description: '',
    url: '',
    logo: '',
    phone: '',
    email: '',
    priceRange: '$$',
    street: '',
    parroquia: '',
    postalCode: '',
    lat: null,
    lng: null,
    nrt: '',
    hours: {
        mon: { closed: false, open: '09:00', close: '18:00' },
        tue: { closed: false, open: '09:00', close: '18:00' },
        // ... same for wednesday to friday
        sat: { closed: false, open: '10:00', close: '14:00' },
        sun: { closed: true,  open: '10:00', close: '14:00' },
    },
    languages: { ca: true, es: true, fr: true, en: false, pt: false },
    areaAll: true,
    areaParroquias: {
        'Andorra la Vella': false,
        'Escaldes-Engordany': false,
        'Encamp': false,
        'Canillo': false,
        'Ordino': false,
        'La Massana': false,
        'Sant Julia de Loria': false,
    },
    social: { instagram: '', facebook: '', linkedin: '', x: '' },
});
Enter fullscreen mode Exit fullscreen mode

Sunday is pre-set as closed. Saturday has shorter hours. These reflect reality for most Andorran businesses. Good defaults reduce friction. Many languages are also pre checked.

Parish-to-postal-code mapping

When a user selects a parish, the postal code auto-fills:

postalCodes: {
    'Andorra la Vella': 'AD500',
    'Escaldes-Engordany': 'AD700',
    'Encamp': 'AD200',
    'Canillo': 'AD100',
    'Ordino': 'AD300',
    'La Massana': 'AD400',
    'Sant Julia de Loria': 'AD600',
},

onParroquiaChange() {
    if (this.form.parroquia && !this.form.postalCode) {
        this.form.postalCode = this.postalCodes[this.form.parroquia] || '';
    }
}
Enter fullscreen mode Exit fullscreen mode

*Please note: Parroquia = parish.

Seven parishes, seven postal codes. This is the kind of local knowledge that no generic tool has.

Geocoding with Nominatim

Instead of asking users to manually find their coordinates, the tool geocodes the address using the Nominatim API (OpenStreetMap):

async geocode() {
    if (!this.form.street || !this.form.parroquia) return;
    this.geocoding = true;
    this.geocodeError = false;
    try {
        const q = encodeURIComponent(
            `${this.form.street}, ${this.form.parroquia}, Andorra`
        );
        const res = await fetch(
            `https://nominatim.openstreetmap.org/search?format=json&q=${q}&limit=1`
        );
        const data = await res.json();
        if (data && data.length > 0) {
            this.form.lat = parseFloat(parseFloat(data[0].lat).toFixed(4));
            this.form.lng = parseFloat(parseFloat(data[0].lon).toFixed(4));
        } else {
            this.geocodeError = true;
        }
    } catch (e) {
        this.geocodeError = true;
    } finally {
        this.geocoding = false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Appending ", Andorra" to the query is critical. Without it, "Avinguda Meritxell" could resolve to half a dozen places worldwide. With it, Nominatim nails the location almost every time.

Coordinates are rounded to 4 decimal places (around 11 meters precision), which is more than enough for a business address.

Building the JSON-LD output

The computed output getter builds the Schema.org object incrementally, only including fields that have values:

get output() {
    if (!this.form.name) return '';
    const ld = {
        '@context': 'https://schema.org',
        '@type': this.form.type,
        name: this.form.name,
    };
    if (this.form.description) ld.description = this.form.description;
    if (this.form.url) ld.url = this.form.url;
    if (this.form.phone) ld.telephone = this.form.phone;
    // ... more optional fields

    // Address is always AD
    const address = { '@type': 'PostalAddress', addressCountry: 'AD' };
    if (this.form.street) address.streetAddress = this.form.street;
    if (this.form.parroquia) {
        address.addressLocality = this.form.parroquia;
        address.addressRegion = this.form.parroquia;
    }
    ld.address = address;

    // NRT as structured identifier
    if (this.form.nrt) {
        ld.identifier = {
            '@type': 'PropertyValue',
            propertyID: 'NRT',
            value: this.form.nrt
        };
    }

    // The opening hours, skipping closed days
    const dayMap = {
        mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday',
        thu: 'Thursday', fri: 'Friday', sat: 'Saturday', sun: 'Sunday'
    };
    const openingHours = [];
    for (const [d, data] of Object.entries(this.form.hours)) {
        if (!data.closed && data.open && data.close) {
            openingHours.push({
                '@type': 'OpeningHoursSpecification',
                dayOfWeek: `https://schema.org/${dayMap[d]}`,
                opens: data.open,
                closes: data.close,
            });
        }
    }
    if (openingHours.length) ld.openingHoursSpecification = openingHours;

    if (this.form.areaAll) {
        ld.areaServed = { '@type': 'Country', name: 'Andorra' };
    } else {
        const served = Object.entries(this.form.areaParroquias)
            .filter(([, v]) => v)
            .map(([k]) => ({ '@type': 'AdministrativeArea', name: k }));
        if (served.length) ld.areaServed = served;
    }

    return JSON.stringify(ld, null, 2);
}
Enter fullscreen mode Exit fullscreen mode

Some notes:

  • addressCountry is hardcoded to 'AD'. This tool is for Andorra, period. One less thing for the user to get wrong.
  • addressLocality and addressRegion both get the parish name. In Andorra, the parish is both the locality and the region.
  • dayOfWeek uses the full Schema.org URI (https://schema.org/Monday), which is the recommended format.
  • areaServed supports both "all of Andorra" (a Country object) and individual parishes (AdministrativeArea objects). This matters for businesses that only operate in specific parishes.

15 business subtypes

The tool offers 15 Schema.org types, not just generic LocalBusiness:

LocalBusiness, Restaurant, Hotel, Store, MedicalBusiness,
Dentist, BeautySalon, DaySpa, HealthClub, RealEstateAgent,
AutoDealer, LegalService, FinancialService,
ProfessionalService, TouristInformationCenter
Enter fullscreen mode Exit fullscreen mode

These map directly to the most common business types in Andorra. A dental clinic should use Dentist, not LocalBusiness. A hotel should use Hotel. The more specific the type, the better Google understands the business and the more relevant the search results.

i18n: the tool itself is trilingual

The entire UI is translated into Spanish, Catalan, and French using Laravel's translation system. Every label, placeholder, helper text, and button has a translation key:

// lang/es/tools.php
'schema' => [
    'title' => 'Generador Schema.org LocalBusiness',
    'label_type' => 'Tipo de negocio',
    'parroquia_andorra_la_vella' => 'Andorra la Vella',
    // ...
]
Enter fullscreen mode Exit fullscreen mode

The SEO angle: why this matters for local businesses

In a market of 93k people, the competition for local search is low but the impact is quite high. Most business websites have zero structured data. Implementing Schema.org correctly gives you an immediate edge:

  1. Rich snippets: opening hours, price range, and ratings directly in search results.
  2. Knowledge Panel: Google can populate your business panel with accurate data instead of guessing from Maps.
  3. AI answers: Google AI Overviews, ChatGPT, and Perplexity pull from structured data. If your schema says you're a dentist in Escaldes-Engordany open until 19:00, that's what the AI will tell users.
  4. Correct country attribution: with addressCountry: "AD", Google stops confusing your business with Spanish or French results.

Building content for micro-markets

In a big market, you build for flexibility. In a micro-market, you build for accuracy. Pre-selecting languages, defaulting Sunday to closed, hardcoding the country code: these limitations are advantages.

Here everyone knows everyone, so providing a useful tool is better than any portfolio page, as chances are business owners use your tool.


The tool is live here (Spanish version). And that's it, hope you enjoyed reading this!

BTW, this is a great place for developers; if you are interested on living here, just ask 😃

Top comments (0)