myfolio.work

For website creators

Developer documentation

Every section of a myfolio.work site is a Liquid template. This page is what you can read inside one, how to loop over your own content, and how to submit a contact form or a newsletter sign-up from your own design.

Your content is already in the template scope before the page renders — no API calls needed. Your markup, scripts included, is published exactly as you write it.

What a component can read

These are in scope in every component, on every page. Nothing has to be fetched.

FieldTypeWhat it is
website.namestringThe site's name, as set in Settings.
website.urlstringIts full address, ready for a canonical link.
website.logoUrlstring | nullThe uploaded logo, or null. Always test before drawing an <img>.
website.menuarrayThe site menu: a list of { label, href }.
website.contactobjectemail, phone, address, mapQuery, socials — only what the owner chose to publish.
website.seoobjecttitle, description, keywords. The title already falls back to the site name.
page.pathstringThe address being rendered, e.g. /about.
page.titlestringThis page's title.
brand.*objectThe resolved palette — background, text, textMuted, primary, accent, border — plus displayFont and bodyFont.
site.logoUrlstring | nullThe same logo, read live rather than frozen at publish. Prefer website.* for everything else.
showMadeBybooleanWhether this site shows the myfolio.work credit. False on plans that remove it.
madeByTextstringThe finished credit line, already composed and already escaped.
madeByUrlstringWhere that line links to.
node.idstringThis section's own id — unique per placement, for DOM ids a section needs (a menu toggle, an accordion).

The credit in your footer is exactly one line, and it is the only correct way to write it:

{% if showMadeBy %}
  <a href="{{ madeByUrl }}">{{ madeByText }}</a>
{% endif %}

Do not write the words yourself. Your component's source is frozen into the site every time it publishes, so anything typed into the template can only ever be changed by republishing that site — while these three values are read fresh on every render. They are escaped before they reach you, so no escape filter is needed.

Fields a visitor should not see are absent, not hidden — a phone number the owner has not published simply is not in contact, so a template cannot leak what it was never given.

Your services, work, testimonials and careers

What you fill in under Your details arrives as five lists.

FieldTypeWhat it is
servicesarrayYour services.
worksarrayYour projects.
experiencearrayYour roles, newest first.
testimonialsarrayWhat people said.
jobsarrayYour open roles, from the Careers list.

Every row has the same shape, so one loop reads any of them: title, subtitle, body (the short description), imageUrl, url, period. Services and openings additionally carry details (the full description, as HTML from the rich editor), slug and path — the address of the item's own page, e.g. /careers/senior-designer, or null when it has none. Link a card with {% assign link = item.url | default: item.path %}.

{% for service in services %}
  <article>
    {% if service.imageUrl %}
      <img src="{{ service.imageUrl }}" alt="{{ service.title | escape }}">
    {% endif %}
    <h3>{{ service.title }}</h3>
    <p>{{ service.body }}</p>
  </article>
{% else %}
  <p>Nothing added yet.</p>
{% endfor %}

The {% else %} branch runs when the list is empty. Use it — a brand-new site has empty lists, and a section that renders as a bare heading over nothing is the most common thing to get wrong here.

A careers section, using the same row shape:

{% for job in jobs %}
  <article>
    <h3>{{ job.title }}</h3>
    <p>{{ job.subtitle }}{% if job.period %} · {{ job.period }}{% endif %}</p>
    <p>{{ job.body }}</p>
    {% if job.url %}<a href="{{ job.url }}">Apply</a>{% endif %}
  </article>
{% else %}
  <p>No open roles right now.</p>
{% endfor %}

For careers, subtitle is the location, period the employment type and url where to apply. Unpublishing a row in the dashboard closes the role on every page that lists it. The component library also ships a ready-made Careers section that reads this list on its own.

Listing blog posts

On /blogs your published posts are always in scope as blogs, with pagination beside them — and any component can ask for them on any page.

{% for post in blogs %}
  <a href="{{ post.url }}">
    {% if post.coverImageUrl %}
      <img src="{{ post.coverImageUrl }}" alt="{{ post.title | escape }}">
    {% endif %}
    <h3>{{ post.title }}</h3>
    <p>{{ post.excerpt }}</p>
    <time datetime="{{ post.publishedAt }}">{{ post.publishedOn }}</time>
  </a>
{% else %}
  <p>No posts yet.</p>
{% endfor %}

publishedAt is the machine-readable date for a datetime attribute; publishedOn is the same day already written out for a reader. excerpt is what the dashboard calls the post's short description. Posts are read live, so publishing one appears on a site you published last month without republishing anything.

Paging through them:

{% if pagination.totalPages > 1 %}
  <nav>
    {% if pagination.hasPrevious %}
      <a href="?page={{ pagination.previousPage }}">Previous</a>
    {% endif %}
    <span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
    {% if pagination.hasNext %}
      <a href="?page={{ pagination.nextPage }}">Next</a>
    {% endif %}
  </nav>
{% endif %}

Posts on any other page

A component can declare that it needs posts, and they arrive on whatever page it is placed — the home page included. In the component's field config, add a field whose type is blogs:

{
  "heading": { "type": "text", "label": "Heading", "default": "Latest writing" },
  "blogs":   { "type": "blogs", "label": "Blog posts", "limit": 6 }
}

The field is configuration, not a prop: it never shows in the inspector and carries no value. Its presence makes the renderer fetch up to limit published posts (newest first; tag and sort are also accepted) and put blogs, pagination and query in scope — the same names, the same shapes, as on /blogs. The same declaration is honoured everywhere the component renders: the visual editor, the code editor's preview, the component preview and the published site all read one data helper, so what you see is what ships. A page whose components declare nothing fetches nothing.

To show fewer in one placement than another, keep the declared limit generous and trim in the loop: {% for post in blogs limit: 3 %}. The starter library's latest-posts component is a working example.

A single post

On /blogs/:slug, the post being read is in scope as blog.

<article>
  <h1>{{ blog.title }}</h1>
  <time datetime="{{ blog.publishedAt }}">{{ blog.publishedOn }}</time>
  {% if blog.coverImageUrl %}
    <img src="{{ blog.coverImageUrl }}" alt="{{ blog.title | escape }}">
  {% endif %}
  {{ blog.content }}
</article>

An address whose post does not exist is a 404 — the template never renders with blog empty, so you do not have to guard for it.

Pages and paths

A page is an address plus a list of sections.

FieldTypeWhat it is
/pageThe home page. Every site must have one.
/aboutpageAn ordinary page. Lowercase letters, digits and dashes.
/blogs/:slugpatternOne page that renders whichever post the address names.
/services/:slugpatternOne page per service. The service being viewed is in scope as `service`, with `details` holding its full description as HTML.
/careerspageThe openings list — a career-list section reads your Careers rows. This is the page each opening links back to.
/careers/:slugpatternOne page per opening, in scope as `job`. An unpublished or deleted opening 404s.
/(layout)reservedThe shared layout. Not a URL — visitors cannot reach it.

A segment beginning with : is a placeholder, and the page it belongs to serves every address that matches. An exact page always wins over a pattern, so a real /blogs/about beats the catch-all rather than being swallowed by it.

Add pages to your menu in Settings rather than hard-coding links in a component — the header and the footer both read website.menu, so one edit changes every place a menu appears.

The shared layout

Header, footer, and anything else that belongs on every page.

The page at /(layout) wraps every other page. Put your header and footer in it once, with a Page content section marking where each page’s own sections are dropped in:

/(layout)
  ├── header
  ├── Page content   ← every page renders here
  └── footer

Without this, a header is copied into every page — which means one edit per page to change a menu item, and copies that drift apart. If the Page content marker is missing, pages render after the whole layout, below the footer. That is deliberately obvious: it means the marker was deleted.

Contact forms and newsletter

Build the form however you like, then submit it with one of two functions every published page provides.

Every published page defines window.submitContactForm and window.submitNewsletter before any of your sections’ scripts run. There is nothing to include or import — call them from your component’s own <script>.

window.submitContactForm(values, formKey?)

FieldTypeWhat it is
valuesobjectA plain object. Any field names you like — name, email, phone, company, budget, message, anything. Every field is stored and shown in Enquiries and in the owner's notification email. Names up to 60 characters; values are sent as text, up to 5,000 characters each.
values.namestringOptional, but used as the sender's name in Enquiries (fullname and full_name also work).
values.emailstringOptional, but used as the reply-to address in Enquiries (e-mail and emailaddress also work). Not validated for you — check it before sending if it matters.
formKeystringWhich inbox it belongs to. Defaults to "contact", which every site has. Any other key must be written as a literal in your source — submitContactForm(values, "quote") — and is created when the site is published. A key the site does not have is refused (ok: false).

window.submitNewsletter(email, name?)

FieldTypeWhat it is
emailstringRequired, must be a valid address (up to 254 characters). You can also pass one object: submitNewsletter({ email, name }).
namestringOptional, up to 200 characters.
Double opt-inbehaviourThe visitor is emailed a confirmation link and only counts as a subscriber once they click it. The message you get back says so: “Almost there — check your email to confirm.”

What both return

FieldTypeWhat it is
Promise<{ ok, message }>returnAlways resolves — never rejects, never throws. You do not need try/catch.
ok: truebooleanReceived. message is the form's thank-you text (for newsletter: the check-your-email line). Clear your inputs and show message.
ok: falsebooleanDid not send — offline, too many attempts, an invalid newsletter email, or an unknown form key. message says what to tell the visitor. Keep their input and let them try again.

Limits and spam protection

  • Rate limits: 10 contact submissions and 5 newsletter sign-ups per minute, per visitor, per site. Beyond that the call returns ok: false.
  • Spam is filtered silently. A submission sent within 2 seconds of the page loading is flagged; one with 2+ links is flagged and 4+ links is dropped. The visitor still sees ok: true — telling a bot it was caught only teaches it what to change. Flagged ones still reach Enquiries, marked as possible spam.

A complete contact section

Ids are built from {{ node.id }} because a section can appear on a page more than once — fixed ids would wire both copies to the first one.

<div class="contact" id="contact-{{ node.id }}">
  <label>Name <input name="name" autocomplete="name" required></label>
  <label>Email <input name="email" type="email" autocomplete="email" required></label>
  <label>Phone <input name="phone" type="tel" autocomplete="tel"></label>
  <label>Message <textarea name="message" rows="5" required></textarea></label>
  <button type="button">Send message</button>
  <p class="contact__status" role="status" aria-live="polite"></p>
</div>

<script>
  var root = document.getElementById("contact-{{ node.id }}");
  var button = root.querySelector("button");
  var status = root.querySelector(".contact__status");

  button.addEventListener("click", function () {
    var fields = root.querySelectorAll("input[name], textarea[name]");
    var values = {};
    for (var i = 0; i < fields.length; i++) {
      if (!fields[i].checkValidity()) { fields[i].reportValidity(); return; }
      values[fields[i].name] = fields[i].value;
    }

    button.disabled = true;
    button.textContent = "Sending…";
    status.textContent = "";

    window.submitContactForm(values).then(function (result) {
      status.textContent = result.message;
      status.className = "contact__status " + (result.ok ? "is-ok" : "is-error");
      if (result.ok) {
        for (var j = 0; j < fields.length; j++) fields[j].value = "";
      }
      button.disabled = false;
      button.textContent = "Send message";
    });
  });
</script>

A newsletter sign-up

<div class="signup" id="signup-{{ node.id }}">
  <input name="email" type="email" placeholder="[email protected]" autocomplete="email" required>
  <button type="button">Subscribe</button>
  <p class="signup__status" role="status" aria-live="polite"></p>
</div>

<script>
  var root = document.getElementById("signup-{{ node.id }}");
  var input = root.querySelector("input");
  var button = root.querySelector("button");
  var status = root.querySelector(".signup__status");

  button.addEventListener("click", function () {
    if (!input.checkValidity()) { input.reportValidity(); return; }
    button.disabled = true;
    window.submitNewsletter(input.value).then(function (result) {
      status.textContent = result.message; // "Almost there — check your email to confirm."
      if (result.ok) input.value = "";
      button.disabled = false;
    });
  });
</script>

Worth knowing

  • Your markup is served as you wrote it. Scripts, onclick=, <form>, iframes, CDN libraries and requests to other services all work. If you post a <form> to a service of your own instead, those submissions go there, not to Enquiries.
  • Scripts are scoped. Each <script> runs in its own scope; put anything shared on window. Use type="button" for your button — a submit button inside a <form> reloads the page unless you prevent it.
  • In the visual editor both functions resolve { ok: true, message: "Preview only — forms send on your published site." } and send nothing, so testing a design never fills the inbox.
  • Your site is signed out to the platform. Requests a published page makes to myfolio.work are always treated as anonymous.

Prefer no script at all? Put your inputs inside <div data-ze-form="contact">…</div> (or data-ze-newsletter) with a type="submit" button, and the platform submits every named field inside it the same way.

There is also a map marker: <div data-ze-map="Kathmandu, Nepal"></div> becomes an embedded Google map of that place — or embed any map yourself with an <iframe>.

Making a template

A template is a set of pages, components and brand settings that a new site starts from.

  1. Build a site the way you want the template to start.
  2. Put the header and footer in /(layout), not on each page.
  3. Leave content lists empty and let {% else %} handle it. A template full of placeholder names ships those names to whoever uses it.
  4. Use {{ website.name }} rather than typing a name — one person runs several sites, and a typed name goes stale the moment the template is reused.
  5. Read colours from brand.* instead of writing hex values, so changing the palette restyles the whole site.
  6. Save it as a template from the site’s menu, then apply it to a new site.

Templates are reviewed before they appear in the public library. Your own are usable immediately.

How templates run

What the renderer does with your source — worth knowing when something looks wrong.

  • Templates run on standard Liquid. Every built-in tag and filter works — for, if, assign, capture, date, default, escape and the rest. There are no custom filters: if a filter is not in Liquid, it is not here.
  • Unknown variables and filters render as empty rather than failing the page. A blank where you expected a value usually means a misspelt name.
  • Parsing and rendering are budgeted. A template that loops forever or grows the page without end is stopped, and the section reports a problem instead of hanging the site.
  • Your markup is published exactly as written — scripts, forms, iframes and event handlers included. The one boundary is the platform itself: requests a published page makes to myfolio.work are always treated as signed out.
  • A section can appear on a page more than once, so never hard-code a DOM id. Derive it: id="menu-{{ node.id }}".
  • Component defaults render first, then the values typed into the section. A field left empty in the editor falls back to the default declared by the component.

Something missing here? Get in touch — this guide grows from what people ask.