Turn an HTML site into an Arc CMS site

This page converts a finished HTML site, step by step: a home page, a blog list and a blog post. At the end the home page shows the latest posts and a signup form, the blog is a content type with its own fields that editors fill in the admin, and the site works in a second language. Every file is in docs/examples/blog/, before and after.

Before you start

What you start with

The example site, Slow Kitchen, is in docs/examples/blog/original/:

FileWhat it is
docs/examples/blog/original/index.htmlThe home page: a hero, a signup form and three post cards written by hand
docs/examples/blog/original/blog.htmlThe blog list: every post as a card
docs/examples/blog/original/post.htmlOne post: a cover image, the title, date, author, reading time, the body and a "Read next" list
docs/examples/blog/original/style.css, docs/examples/blog/original/main.jsOne stylesheet and one script that every page loads

Each page repeats the same navigation and footer, and the cards and post are written by hand. After the conversion they are filled from content an editor writes in the admin.

1. Sort the pieces

Decide where each part of the old site goes. This is the whole plan:

Part of the old siteBecomesKind of file
The navigation barsrc/custom/site/header.htmlHTML fragment, on every page
The footersrc/custom/site/footer.htmlHTML fragment, on every page
Styles every page sharessrc/custom/site/site.cssLoaded on every website page and the sign-in page
The home pagesrc/custom/site/home.htmlA whole HTML document
The home page's own styles, the scriptsrc/custom/site/assets/home.css, src/custom/site/assets/main.jsFiles, served at /site/home.css and /site/main.js
The blog listsrc/custom/site/templates/blog/list.htmlTemplate fragment
One postsrc/custom/site/templates/blog/detail.htmlTemplate fragment
A post card on the home pagesrc/custom/site/templates/blog/partials.htmlTemplate fragment
Fixed text in Hindisrc/custom/site/strings/hi.jsonTranslations

Sections of the home page that editors should change without you, such as services, a hero or questions, become content types without public pages; contact details in the footer come from Settings, About. This example keeps them as HTML; Let editors change your home page shows the other way.

The home page is a whole document, with its own <head>. Templates are fragments: no <html>, <head> or <body>. Arc CMS writes the page around them, with the title, description and search tags of each post. The folder name, blog, matches the content type's slug, so the admin picks it for you.

2. Header, footer and shared styles

Cut the navigation and the footer out of the old pages into their own files. Links start with / (the home page is /, the blog is /blog), not index.html. data-arc-t marks text a second language will translate, and <arc-language-switcher> becomes links to the page's other languages (it disappears while the site has one).

<!-- The site header on every page: home, blog list, posts, static pages.
     Root-relative links (/ and /blog) point at the page's language on
     translated pages. -->
<nav class="site-nav">
    <a class="logo" href="/">Slow Kitchen</a>
    <ul>
        <li><a href="/" data-arc-t="nav_home">Home</a></li>
        <li><a href="/blog" data-arc-t="nav_blog">Blog</a></li>
    </ul>
    <arc-language-switcher></arc-language-switcher>
</nav>
<footer class="site-footer" data-arc-t="footer_note">Slow Kitchen. Cooked and written at home.</footer>

Move the styles every page shares into src/custom/site/site.css. Arc CMS links Bootstrap and its own main.css first, then site.css, so your rules win. The card styles go here too, because the home page and the blog list both show cards.

/* Styles every page of the site shares: loaded on the home page, the blog
   list and posts, static pages and the sign-in page (after Bootstrap and
   Arc CMS's main.css, so these rules win). */
:root { --ink: #1f2933; --muted: #616e7c; --brand: #c2410c; --paper: #fffaf5; }
body { margin: 0; font-family: Georgia, 'Times New Roman', serif; color: var(--ink); background: var(--paper); }
a { color: var(--brand); }

.site-nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 2rem; border-bottom: 1px solid #f0e4d7; }
.site-nav .logo { font-weight: 700; font-size: 1.25rem; color: var(--ink); text-decoration: none; }
.site-nav ul { display: flex; gap: 1.5rem; list-style: none; margin: 0 0 0 auto; padding: 0; }
.site-nav ul a { color: var(--ink); text-decoration: none; }

.site-footer { padding: 2rem; text-align: center; color: var(--muted); border-top: 1px solid #f0e4d7; margin-top: 4rem; }

/* Cards: the home page's latest posts and the blog list. */
.posts { max-width: 1100px; margin: 0 auto; padding: 0 2rem; }
.post-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
.card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
.card img { width: 100%; height: 180px; object-fit: cover; display: block; }
.card .body { padding: 1rem 1.25rem 1.25rem; }
.card h3 { margin: 0 0 .5rem; font-size: 1.2rem; }
.card h3 a { color: var(--ink); text-decoration: none; }
.card .meta { color: var(--muted); font-size: .85rem; }

3. The home page

Copy docs/examples/blog/original/index.html to src/custom/site/home.html and change four things:

  1. The navigation and footer become <arc-header></arc-header> and <arc-footer></arc-footer>.
  2. The three hand-written cards become one element, <arc-content-partials content-type="blog" count="3" section-title="Latest from the blog">. It shows the newest three posts, drawn by partials.html (step 6).
  3. The signup form gets data-waitlist-form and data-waitlist-id="newsletter". Arc CMS sends each sign-up to the form named newsletter, asks for the emailed code, and adds the terms notice above the button. If no form with that id exists, it is created the first time the page runs, so check the spelling, or create it first under Signup Forms with the slug newsletter.
  4. Styles and the script move to src/custom/site/assets/ and are linked as /site/home.css and /site/main.js. Arc CMS adds each file's version to the link, so a change reaches returning visitors.
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title data-arc-t="home_title">Slow Kitchen: simple seasonal cooking</title>
    <meta name="description" content="Recipes and notes on cooking with what is in season.">
    <link rel="stylesheet" href="/site/home.css">
</head>
<body>
    <arc-header></arc-header>

    <section class="hero">
        <h1 data-arc-t="home_heading">Cook with the season</h1>
        <p data-arc-t="home_lead">Short, honest recipes for whatever the market has this week.</p>
        <form class="signup" data-waitlist-form data-waitlist-id="newsletter">
            <input type="email" name="email" placeholder="Your email" data-arc-t-attr="placeholder:signup_email">
            <button type="submit" data-arc-t="signup_button">Get the weekly letter</button>
        </form>
    </section>

    <arc-content-partials content-type="blog" count="3" section-title="Latest from the blog"
        data-arc-t-attr="section-title:latest_posts"></arc-content-partials>

    <arc-footer></arc-footer>
    <script src="/site/main.js" defer></script>
</body>
</html>
/* The home page only: linked from home.html as /site/home.css. */
.hero { padding: 5rem 2rem; text-align: center; }
.hero h1 { font-size: 3rem; margin: 0 0 1rem; }
.hero p { color: var(--muted); font-size: 1.25rem; }
.signup { display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; margin-top: 2rem; }
.signup input { padding: .75rem 1rem; border: 1px solid #e4d5c5; border-radius: 6px; min-width: 260px; }
.signup button { padding: .75rem 1.25rem; border: 0; border-radius: 6px; background: var(--brand); color: #fff; }
/* Arc CMS adds the terms notice just before the button: give it its own line, under the form. */
.signup .arc-legal-notice { flex-basis: 100%; order: 3; text-align: center; }

The last rule in home.css puts the terms notice on its own line: Arc CMS inserts it just before the button, which in a one-line form squeezes it between the field and the button.

The script now starts either way, whether the page is still loading or already loaded. In npm run dev your scripts run after the page has loaded, so a script that waits for DOMContentLoaded would never start there:

// Fades cards in as they scroll into view. Runs whether the page is still
// loading (the published page) or already loaded (the preview in npm run dev).
(function () {
    function start() {
        if (!('IntersectionObserver' in window)) return;
        var seen = new IntersectionObserver(function (entries) {
            entries.forEach(function (entry) {
                if (entry.isIntersecting) { entry.target.style.opacity = 1; seen.unobserve(entry.target); }
            });
        });
        document.querySelectorAll('.card').forEach(function (card) {
            card.style.opacity = 0;
            card.style.transition = 'opacity .6s';
            seen.observe(card);
        });
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
    else start();
})();

Run npm run dev and open /: the header, hero, form and footer appear from your files. The cards stay empty until the blog has a published post.

4. The Blog content type

Do this after the template files exist in src/custom/site/templates/blog/ (steps 5 to 7), so the admin offers the folder. In the admin, open Content, Content types, Add Content Type:

SettingValue
Content Type Name (Plural)Blog
Singular NamePost
DescriptionEvery recipe and kitchen note, newest first. (The list page shows it as {{ contentTypeDescription }}.)
Generate public pagesOn
Content Type Slugblog, filled in from the name. It is the address (/blog) and the name the content is stored under, so do not change it once posts exist.
Content TemplateBlog, picked for you because the folder has the slug's name
Field 1Name Hero Image, type image. Its key is blog-hero-image.
Field 2Name Related Links, type labelvalue (rows of a label and a value). Its key is blog-related-links.
Structured dataBlog post

Title, body, summary, author, tags, dates and the reading time are built in, so the old page's date, author and "6 min read" need no fields. A template writes a custom field by its key ({{ blog-hero-image }}) or by the key without the type's slug ({{ hero-image }}); this example uses the short form. An image field also has four sizes: {{ hero-image_s }}, _m, _l and _xl. Every field type and what a template gets from it is in the field types reference.

5. The blog list

Copy the middle of docs/examples/blog/original/blog.html into src/custom/site/templates/blog/list.html: no doctype, <head> or <body>. Then keep one card and turn it into the pattern every post repeats:

OldNew
Navigation and footer<arc-header> and <arc-footer>, where they should appear
"The blog", the intro line{{ contentType }}, {{ contentTypeDescription }}: the type's name and description, translated with the type
Two hand-written cardsOne card inside data-arc-loop="items". The loop repeats its first child once per post, newest first (up to 100)
post.html, the title, date, "6 min read", the teaser{{ url }}, {{ title }}, {{ publishedOn }}, {{ readTime }}, {{ excerpt }} (the meta description, or the start of the body)
images/squash.jpg{{ hero-image_m }}, with data-arc-if="hero-image" so a post without one shows no broken image
<!-- The blog list page, /blog. A fragment: publishing wraps it in a page with
     the type's name as the title and its description. -->
<arc-header></arc-header>

<section class="posts">
    <h1>{{ contentType }}</h1>
    <p class="intro">{{ contentTypeDescription }}</p>
    <div class="post-grid" data-arc-loop="items">
        <article class="card">
            <img data-arc-if="hero-image" src="{{ hero-image_m }}" alt="">
            <div class="body">
                <h3><a href="{{ url }}">{{ title }}</a></h3>
                <p class="meta">{{ publishedOn }} · <span data-arc-t="min_read">{{ readTime }} min read</span></p>
                <p>{{ excerpt }}</p>
            </div>
        </article>
    </div>
</section>

<arc-footer></arc-footer>
<script src="/site/main.js" defer></script>

Publishing puts the list in a page whose title is the type's name and whose description is the type's description. The <script> moves to the end of the page.

6. The cards

src/custom/site/templates/blog/partials.html draws the cards for <arc-content-partials content-type="blog"> on the home page. It has the same items as the list, plus {{ sectionTitle }} (the element's section-title), {{ listUrl }} (the list page, in the page's language) and hasItems, which hides the whole block while there are no posts:

<!-- Cards for <arc-content-partials content-type="blog">: the home page's
     latest posts. A fragment: no <html>, <head> or <body>. -->
<section class="posts" data-arc-if="hasItems">
    <h2>{{ sectionTitle }}</h2>
    <div class="post-grid" data-arc-loop="items">
        <article class="card">
            <img data-arc-if="hero-image" src="{{ hero-image_m }}" alt="">
            <div class="body">
                <h3><a href="{{ url }}">{{ title }}</a></h3>
                <p class="meta">{{ publishedOn }}</p>
            </div>
        </article>
    </div>
    <p><a href="{{ listUrl }}" data-arc-t="all_posts">All posts</a></p>
</section>

7. One post

src/custom/site/templates/blog/detail.html is the middle of docs/examples/blog/original/post.html, with the post's values in place of the written ones:

OldNew
The cover image{{ hero-image_l }}, inside data-arc-if="hero-image"
Date, author, reading time{{ publishedOn }}, {{ authorName }}, {{ readTime }}
The article's paragraphs<div [innerHTML]="content"></div>. The body is HTML; {{ content }} would show its tags as text. The same goes for any rich text field.
The "Read next" listOne <li> inside data-arc-loop="related-links", with {{ label }} and {{ value }} from each row
blog.html{{ langPrefix }}/blog, so a Hindi post links back to the Hindi list
The post's own stylesA <style> block in the template; publishing moves it into the page's head
<!-- One blog post, /blog/{slug}. A fragment: publishing wraps it in a page
     with the post's SEO title, description and structured data. -->
<arc-header></arc-header>

<article class="post">
    <img class="cover" data-arc-if="hero-image" src="{{ hero-image_l }}" alt="{{ title }}">
    <h1>{{ title }}</h1>
    <p class="meta">
        {{ publishedOn }}
        <span data-arc-if="authorName">· <span data-arc-t="by_author">by {{ authorName }}</span></span>
        · <span data-arc-t="min_read">{{ readTime }} min read</span>
    </p>
    <!-- The body is HTML: [innerHTML], never {{ content }}, which would show the tags. -->
    <div class="content" [innerHTML]="content"></div>

    <aside class="related" data-arc-if="related-links">
        <strong data-arc-t="read_next">Read next</strong>
        <ul data-arc-loop="related-links">
            <li><a href="{{ value }}">{{ label }}</a></li>
        </ul>
    </aside>

    <p><a href="{{ langPrefix }}/blog" data-arc-t="back_to_blog">Back to the blog</a></p>
</article>

<arc-footer></arc-footer>

<style>
    /* Posts only: publishing moves this into the page's <head>. */
    .post { max-width: 720px; margin: 0 auto; padding: 2rem; }
    .post .cover { width: 100%; border-radius: 10px; }
    .post h1 { font-size: 2.5rem; margin: 1.5rem 0 .5rem; }
    .post .meta { color: var(--muted); }
    .post .content { font-size: 1.15rem; line-height: 1.7; }
    .related { margin-top: 3rem; padding: 1.25rem; background: #fff; border-radius: 10px; }
    .related ul { margin: .5rem 0 0; padding-left: 1.25rem; }
</style>

8. Try it in npm run dev

  1. Open Content, Blog, Add Post. Write the title and body, pick a hero image with Select image, and add two Related Links rows (a label, and the link as the value). The URL slug is made from the title.

  2. Press Save as draft, then open /blog/{{ your-slug }}?preview=true. You see the post drawn by your detail.html: the cover, title, date, author, reading time, body and links.

  3. The editor says Live site differs beside Publish: your new files are not on the live site yet, and publishing reads the live ones. Publishing now fails with "Template folder 'blog' is not on the live site. Deploy the website, then publish again." The post is still saved as published, so /blog and the home page's cards show it in npm run dev.

9. Put it live

  1. Deploy the website: npm run deploy and choose Website (see Deploy). It puts your files on the live site, keeps the pages already published, and then publishes every page again, your posts included.

  2. Open the live site: / shows the newest posts as cards, /blog the list, and each post its own page with its title and description in the page's head.

  3. From now on, publishing a post updates its page, the list, the home page's cards, the sitemap and the feeds. A change to your files reaches the live site with the next website deploy.

10. Add a second language

  1. Turn the language on under Settings, Languages (see Publish in more than one language).

  2. Put the translations of every data-arc-t key in src/custom/site/strings/hi.json. A translation may hold a value, such as {{ readTime }}, which is filled in afterwards. data-arc-t-attr="placeholder:signup_email" translates an attribute; the home page uses it for the field's placeholder and for the cards' section-title.

    {
      "nav_home": "होम",
      "nav_blog": "ब्लॉग",
      "footer_note": "स्लो किचन। घर पर पकाया और लिखा गया।",
      "home_title": "स्लो किचन: मौसम के साथ सादा खाना",
      "home_heading": "मौसम के साथ पकाइए",
      "home_lead": "इस हफ़्ते बाज़ार में जो मिले, उसके लिए छोटी और सच्ची रेसिपी।",
      "signup_email": "आपका ईमेल",
      "signup_button": "हर हफ़्ते की चिट्ठी पाइए",
      "latest_posts": "ब्लॉग से नया",
      "all_posts": "सभी पोस्ट",
      "min_read": "{{ readTime }} मिनट में पढ़ें",
      "by_author": "{{ authorName }} द्वारा",
      "read_next": "आगे पढ़ें",
      "back_to_blog": "ब्लॉग पर लौटें"
    }
  3. Translate the type's name and description in the content type's language tab, and each post in the editor's language tab. A post you do not translate shows in the default language.

  4. Deploy the website. /hi, /hi/blog and /hi/blog/{{ your-slug }} appear, linked to each other by the language switcher. Links to the home page, the blog and posts take the language; links to sign-in or your app's own pages keep their address.

The Hindi text is only an example; have yours checked by a native speaker.

Common mistakes

You seeWhy, and what to do
"Template folder 'blog' is not on the live site"The website was not deployed after adding the folder. Deploy it, then publish again.
The post shows the default designThe type's Content Template is still Default, or your template file is a whole document (it has <html>, <head> or <body> tags). Keep templates fragments.
The body shows tags such as <p>It was written {{ content }}. Use [innerHTML]="content".
A list shows one post only, or the cards are emptyThe loop keeps only its first child as the pattern: wrap each card in one element. Cards also need hasItems true, that is, a published post.
An empty form appears under Signup FormsThe page's data-waitlist-id named a form that did not exist, so one was created. Fix the spelling, and delete the extra form.
An icon disappears from a translated buttondata-arc-t replaces the element's text, children included. Put icons outside the element that has data-arc-t.
A script works live but not in npm run devIt waits for DOMContentLoaded, which has already happened in the app. Start it as main.js does.

Next

Your mockup has services, a hero, questions or a footer with contact details? Let editors change your home page turns those into content admins edit. The About, Contact and policy pages come ready-made: Write static pages.

The full template reference is Templates, and the home page's details are on Build your home page.