Finally, a Static Site Generator That Speaks C#: Getting Started with Statiq

1. Introduction

For years, the world of Static Site Generators (SSGs) has been dominated by ecosystems outside of .NET. We’ve watched from the sidelines as JavaScript developers enjoyed Gatsby and Next.js, and Go developers embraced Hugo. For .NET developers, the options were often limited, outdated, or required leaving our comfortable C# environment to wrestle with unfamiliar build chains.

Enter Statiq.

Statiq isn’t just another static site generator; it is a powerful, flexible framework designed specifically for the .NET ecosystem. It allows you to use the languages, libraries, and patterns you already know and love — Razor, Markdown, and C# — to build lightning-fast static websites. Whether you are building a simple blog, a documentation site, or a complex enterprise web application, Statiq brings the robustness of .NET to the JAMstack.

Do you want to be a good trading in cTrader?   >> TRY IT! <<

In this guide, I’ll walk you through exactly what Statiq is, why it’s a game-changer for .NET developers, and how you can get your first site up and running in minutes.

If you are not Medium member, please click here to read.

At the end, I will provide a full site and theme with full features below to you!

  1. Search function
  2. Use tags in the site like a blog system
  3. Add the Google AdSense and Analytics service
  4. Archives page
  5. Listing page for the category

2. Why do we need to generate the static website?

Maybe you will wonder why we need to do that? We can use the WordPress to create a beautiful site easily. Yes, you can! But for hosting a WordPress website, you need to handle the following issues:

  1. A host can support PHP (I think maybe this is not an issue)
  2. Performance issue (with a log of plugins )
  3. Security issue. All dynamic websites are at risk of being hacked because they may have some unknown vulnerabilities or you may not update them in time. (I’ve had my WP site hacked before 🥲)

Conversely, however, you can find the following benefits of using a static website

  1. You can use a very cheap hosting only support the HTML
  2. You can get very good performance
  3. Your site will be very secure, because the static site is completely without vulnerabilities, if your host only supports HTML, then the other side will be difficult to inject and other attacks (This is what I like 😀)
  4. You can easily convert any HTML template to your theme
  5. The Pure HTML static websites are very SEO friendly

3. What’s Statiq?

At its core, Statiq is a powerful, flexible static generation framework built on the modern .NET platform. If you are coming from the world of JavaScript (Gatsby, Next.js) or Go (Hugo), you can think of Statiq as the .NET equivalent, but with a significant architectural twist.

While most Static Site Generators (SSGs) are opinionated “tools” configured via YAML or TOML files, Statiq is designed as a framework. This means you don’t just configure your site; you program it.

The Philosophy: Pipelines and Modules

Statiq operates on a unique concept of documentspipelines, and modules:

  • Everything is a Document: In Statiq, a “document” isn’t just a Markdown file. It can be an image, a data file, a Razor template, or even an API response.
  • Pipelines: You define pipelines to process these documents. A pipeline is simply a sequence of steps.
  • Modules: These represent the individual steps within a pipeline. For example, you might have a module to ReadFiles, another to RenderMarkdown, and a final one to WriteFiles.

This architecture gives you granular control over how your content is fetched, transformed, and generated.

Why Choose Statiq?

  1. The .NET Ecosystem: You can leverage the full power of C# and NuGet packages. If you need to fetch data from a database or call a custom API during the build process, you can simply write C# code to do it.
  2. Razor & Markdown: It has first-class support for Razor (the view engine used in ASP.NET Core) and Markdown, allowing you to build complex layouts with logic while writing content in simple text.
  3. Statiq.Web: While the core framework is generic, Statiq.Web is a specialized library built on top of it. It comes pre-configured with common pipelines for assets, content, and templates, allowing you to get a standard website up and running with minimal code.

The workflow in Statiq

Statiq Workflow

In short, Statiq bridges the gap between the simplicity of a static site and the dynamic capabilities of a .NET application.

4. Create and support multiple themes

Statiq is very easy to use, you can follow the office guide to create your first app here. And I will show you more details on how to create your own theme and a complete website with Statiq! 😁

4.1 Multiple themes

By default, Statiq guide only provides the simple and single theme structure below

But I want to support multiple themes and can easily use them in appsettings.json

To support multiple themes, we need to create a themes folder, and create a theme folder, put the template pages into the theme’s input folder (actually, this is not necessary; you can also put the pages into the theme root folder).

Create the appsettings.json in project root with below content

{
    "Theme": "themes/editorial"
}

Read the config in Program.cs when creating the Boststrapper

return await Bootstrapper
              .Factory
              .CreateWeb(args)
              .ConfigureSettings(settings =>
              {
                  if (!settings.ContainsKey("Theme"))
                  {
                      settings["Theme"] = "themes/default";
                  }
              })

And handle the theme template files in ConfigureFileSystem in Program.cs

.ConfigureFileSystem((fileSystem, settings) =>
 {
     var themePath = settings.GetString("Theme");
     if (!string.IsNullOrEmpty(themePath))
     {
         // Create a validated path from the setting
         var themePathNormalized = new NormalizedPath(themePath);

         // Ensure the path is absolute. If it's relative, combine it with the root path.
         if (themePathNormalized.IsRelative)
         {
             themePathNormalized = fileSystem.RootPath.Combine(themePathNormalized);
         }

         // Check if the theme has an 'input' folder (common convention)
         // If it does, strictly use that as the input path instead of the theme root
         var themeInputPath = themePathNormalized.Combine("input");

         if (fileSystem.GetDirectory(themeInputPath).Exists)
         {
             fileSystem.InputPaths.Add(themeInputPath);
         }
         else
         {
             fileSystem.InputPaths.Add(themePathNormalized);
         }
     }
 })

That’s it, then we can switch the theme any time.

4.2 Create the theme layout

Ok, next, I will base on a free HTML template of the HTML 5UP to create a Statiq theme.

We can to take a look at the HTML template first

as this layout, we should create some pages as below

  1. _Layout.cshtml : The main structure
  2. Index.cshtml: The home page
  3. _Sidebar.cshtml: The sidebar

These are the main pages, but we need more, like archives, tags, search, and 404 pages…

So the theme folder structure should be like below

I already created a website Tableware.com that is based on Statiq.

So I will use this website to explain how to create the theme.

First, we define the main layout structure in _Layout.cshtml

<!-- Wrapper -->
    <div id="wrapper">

        <!-- Main -->
        <div id="main">
            <div class="inner">

                <!-- Header -->
                <header id="header">
                    <a href="/" class="logo"><strong>Tableware</strong></a>
                    @* <ul class="icons">
                        <li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
                        <li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
                        <li><a href="#" class="icon brands fa-instagram"><span class="label">Instagram</span></a></li>
                    </ul> *@
                </header>
                @RenderBody()
            </div>
        </div>

        <!-- Sidebar -->
        @await Html.PartialAsync("_Sidebar")

    </div>

and create the sidebar, because there are too many codes, so I will only show the core logic on how to get the posts in views, but don’t worry, I will provide all source code at the end:

<div class="mini-posts">
                @{
                    // 1. Get all posts in the "posts" folder
                    // 2. Filter out posts without a date
                    // 3. Order by date in descending order
                    // 4. x.ContainsKey("Date"): only find the post with "Date" metadata,
                    // exclude pages like "About Us" without date
                    // 5. !x.Source.IsNull: exclude pages like search-index.json
                    // 6. Make sure the source contain "posts"
                    var recentPosts = OutputPages
                        .Where(x => x.ContainsKey("Date") && !x.Source.IsNull && x.Source.ToString().Contains("posts"))
                        .OrderByDescending(x => x.Get<DateTime>("Date"))
                        .Take(3);
                }

                @if (recentPosts.Any())
                {
                    foreach (var doc in recentPosts)
                    {
                        <article>
                            @if (doc.ContainsKey("Image"))
                            {
                                <a href="@doc.GetLink()" class="image">
                                    <img src="@doc.GetString("Image")" alt="@doc.GetString("Title")" />
                                </a>
                            }

                            <h3 style="font-size: 1em;">
                                <a href="@doc.GetLink()">@doc.GetString("Title")</a>
                            </h3>

                            <p style="font-size: 0.8em; margin-bottom: 0;">
                                @(doc.Get<DateTime>("Date").ToLongDateString())
                            </p>
                        </article>
                    }
                }
                else
                {
                    <p>No updates yet.</p>
                }
            </div>
            <ul class="actions">
                <li><a href="/archives" class="button">More Archives</a></li>
            </ul>
        </section>

You can get the post from input/post folder, suppose the posts are created with markdown format

and the code snippet on the home page ( index.cshtml) for showing how to get posts to display

<div class="posts">
            @{
                // 1. Scan all output pages
                // 2. Filter condition: source file not null + source file path contains "posts" + must be .md file (exclude index.cshtml)
                // 3. Sort: use Get<DateTime> to sort by date in descending order
                var recentPosts = OutputPages
                    .Where(x => x.Source != null
                             && x.Source.ToString().Contains("posts")
                             && x.Source.Extension == ".md"
                             && x.ContainsKey("Date"))
                    .OrderByDescending(x => x.Get<DateTime>("Date"))
                    .Take(6);
            }

            @foreach (var doc in recentPosts)
            {
                <article>
                    <a href="@doc.GetLink()" class="image">
                        <img src="@(doc.GetString("Image") ?? "/images/pic01.jpg")" alt="@doc.GetTitle()" />
                    </a>
                    <h3>@doc.GetTitle()</h3>

                    <p>@doc.GetString("Description")</p>

                    <ul class="actions">
                        <li><a href="@doc.GetLink()" class="button">Read Review</a></li>
                    </ul>
                </article>
            }
        </div>

5. Core Features

I will show you how to create the core features for a static website in Statiq.

5.1 Search Function

The search function is a necessary function for a website, but our website is only a static HTML site, so how can we do it?

We can use Lunr.js !

Lunr.js is a lightweight, full-text search library designed specifically for client-side use. Unlike traditional search engines (like Elasticsearch or Solr) that require a server to process queries, Lunr runs entirely in the user’s browser.

It works by taking a JSON dataset, building an inverted index in memory, and then performing searches against that index. This makes it a perfect companion for static sites (Jamstack) because it requires no external dependencies, no server-side code, and no API keys. It’s small, fast, and handles fuzzy matching and boosting, providing a “real” search experience on a static HTML page.

Implementation:

Step 1: Generate the Search Index (The Statiq Side)

We need to generate all content into a JSON file. Add the below code in program.cs in ConfigureEngine

.ConfigureEngine(engine =>
                {
                    // Get the default "Content" pipeline
                    // This is the pipeline where Statiq Web processes all Markdown and Razor files
                    var contentPipeline = engine.Pipelines["Content"];

                    // Add a module in the "PostProcess" phase (after all processing is done, but before writing to disk)
                    contentPipeline.PostProcessModules.Add(
                    new SetDestination(Config.FromDocument(doc =>
                    {
                        // 1. Get the source file name
                        var sourceName = doc.Source.FileName.ToString();

                        // 2. Check: If it is our search index file
                        if (sourceName.Contains("search-index.json"))
                        {
                            // [Critical Fix] Use NormalizedPath instead of FilePath
                            return new NormalizedPath("search-index.json");
                        }

                        // 3. Keep other files as they are
                        return doc.Destination;
                    }))
                    );
...
...

And then create a Razor view (e.g., search-index.json.cshtml). This file iterates through all the published documents in the Statiq pipeline.

It will output a single search-index.json file containing the titledescriptionurl, and content (or keywords) for every page.

This effectively turns your site content into a portable database. The below complete code in search-index.json.cshtml

---
Permalink: /search-index.json
---
@using System.Text.Json
@using System.Text.RegularExpressions
@using Statiq.Common
@using System.Net 

@{
    Layout = null;

    // Define the clean function
    Func<string, string> CleanText = (input) =>
    {
        if (string.IsNullOrEmpty(input)) return string.Empty;

        // 1. Remove the HTML tags (like <p>, <div>, etc.)
        var text = Regex.Replace(input, "<.*?>", " ");

        // 2. Decode "   HTML tags
        text = WebUtility.HtmlDecode(text);

        // 3. Remove newlines, tabs, and collapse multiple spaces into one
        text = Regex.Replace(text, @"\s+", " ").Trim();

        return text;
    };

    var searchEntries = new List<object>();

    foreach (var doc in OutputPages)
    {

        // 1. Only index HTML files (by extension)
        if (doc.Destination.Extension != ".html") continue;

        // 2. Must have a title
        if (!doc.ContainsKey("Title") || string.IsNullOrEmpty(doc.GetString("Title"))) continue;

        // 3. Get the file name
        var fileName = doc.Destination.FileName.ToString();

        // 4. Exclude the search page, index page, and the search index itself
        if (fileName.Equals("search.html", StringComparison.OrdinalIgnoreCase)) continue;
        if (fileName.Equals("index.html", StringComparison.OrdinalIgnoreCase)) continue;
        if (fileName.Contains("search-index")) continue; 

        // --- Handle content ---

        // Synchronously get the content (since we are in a synchronous context)
        string rawContent = doc.GetContentStringAsync().GetAwaiter().GetResult();

        // Clean the content
        string cleanContent = CleanText(rawContent);

        // Get the 5000 first characters of the content (or less if the content is shorter)
        if (cleanContent.Length > 5000)
        {
            cleanContent = cleanContent.Substring(0, 5000);
        }

        searchEntries.Add(new
        {
            title = doc.GetString("Title"),
            link = doc.GetLink(),
            description = doc.GetString("Description") ?? "",
            content = cleanContent
        });
    }

    var options = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = false
    };

    string json = JsonSerializer.Serialize(searchEntries, options);
}
@Html.Raw(json)

Step 2: Load the Index (The Client Side)

When the page loads, a simple JavaScript function uses fetch() to retrieve the search-index.json file we generated in Step 1.

Once the JSON is loaded, we pass it to Lunr. Lunr analyzes the text, removes stop words (like “the”, “and”), stems the words (converting “running” to “run”), and builds its internal index for fast retrieval.

We need to create a search.cshtml for that

Title: Search Results
Layout: _Layout.cshtml
---
<section>
    <header class="main">
        <h1>Search Results</h1>
    </header>
    
    <div id="search-results-container">
        <p>Searching...</p>
    </div>
</section>

<script src="https://cdnjs.cloudflare.com/ajax/libs/lunr.js/2.3.9/lunr.min.js"></script>
<script>
    document.addEventListener("DOMContentLoaded", function() {
        const params = new URLSearchParams(window.location.search);
        const query = params.get("query");
        const container = document.getElementById("search-results-container");
        
        if (!query) {
            container.innerHTML = "<p>Please enter a search term.</p>";
            return;
        }

        fetch("/search-index.json")
            .then(response => response.json())
            .then(data => {
                const idx = lunr(function () {
                    this.ref('link');
                    this.field('title');
                    this.field('description');
                    this.field('content');

                    data.forEach(function (doc) {
                        this.add(doc);
                    }, this);
                });

                const results = idx.search(query);
                
                if (results.length === 0) {
                    container.innerHTML = "<p>No results found for '" + query + "'.</p>";
                } else {
                    let html = '<div class="posts">';
                    results.forEach(result => {
                        const item = data.find(d => d.link === result.ref);
                        html += `
                            <article>
                                <h3>${item.title}</h3>
                                <p>${item.description || ''}</p>
                                <ul class="actions">
                                    <li><a href="${item.link}" class="button">Read More</a></li>
                                </ul>
                            </article>
                        `;
                    });
                    html += "</div>";
                    container.innerHTML = html;
                }
            })
            .catch(error => {
                console.error("Error:", error);
                container.innerHTML = "<p>An error occurred.</p>";
            });
    });
</script>

Step 3: Execute the Search

Ok, we have a simple HTML search form in the sidebar for this theme, when you submit the search query, Lunr queries the in-memory index and returns a list of matching document references (specifically the ref ID, which maps back to our URL).

 <!-- Search -->
        <section id="search" class="alt">
            <form method="get" action="/search">
                <input type="text" name="query" id="query" placeholder="Search" />
            </form>
        </section>

Limitations and Scalability: When to Switch?

While the Statiq + Lunr.js approach is powerful, cost-effective, and privacy-friendly, it relies entirely on the client’s browser. It is not a “one-size-fits-all” solution. Here are the realistic limits you should consider:

A. The “Payload” Issue (File Size) Since the entire search index (search-index.json) must be downloaded by the user, the file size grows linearly with your content.

  • Full-Text Indexing: If you index the entire body of every article, the file can become heavy quickly.
  • Optimization: To mitigate this, I recommend indexing only the TitleTags, and a short Excerpt/Description. This keeps the file lightweight even as your blog grows.

B. Browser Performance Lunr needs to parse the JSON and build the inverted index in memory. For a site with thousands of pages, this process can block the main thread, causing the UI to freeze momentarily on slower devices (especially mobile phones).

C. The Verdict: How many pages?

  • Small to Medium Sites (< 1,000 pages): This solution is excellent. The performance impact is negligible.
  • Large Sites (> 2,000 pages): You might start hitting performance bottlenecks. At this scale, the index file size becomes a burden.
  • Enterprise Scale: If you have tens of thousands of documents, you should look into server-side solutions like AlgoliaElasticsearch, or Azure AI Search.

5.2 The Tags Function

If you would like to create a blog site, I think you need a Tags function, you can add the tags for each post, ok, let’s do it!

We need to add a pipeline for handling the tags logic in Program.cs

// We create a new pipeline named "Tags"
engine.Pipelines.Add("Tags", new Pipeline
{
    // Depends on the Content pipeline, ensuring articles have been read
    Dependencies = { "Content" },
    ProcessModules = {
        // A. Read all articles processed by the "Content" pipeline
        new ReplaceDocuments("Content"),
        // B. Filter out articles without Tags to prevent errors
        new FilterDocuments(Config.FromDocument(doc => doc.ContainsKey("Tags"))),
        // C. Core: Group by the "Tags" field
        // This turns 100 articles into (for example) 10 documents, where each document represents a Tag
        new GroupDocuments("Tags"),
        // D. Load the template we just wrote
        new MergeContent(new ReadFiles("_TagLayout.cshtml")),
        // E. Render Razor
        new RenderRazor()
            .WithModel(Config.FromDocument((doc, ctx) => doc)),
        // F. Set output path: /tag/tag-name.html
        new SetDestination(Config.FromDocument(doc =>
        {
            var tagName = doc.GetString(Keys.GroupKey);
            var slug = tagName.ToLower().Replace(" ", "-");
            return new NormalizedPath($"tag/{slug}.html");
        })),
        // [Critical Fix] G. Write files!
        // This step is mandatory, otherwise the files only exist in memory
        new WriteFiles()
    }
});

Create the _TagLayout.cshtml

@{
    Layout = "_Layout.cshtml";
    // Get the current tags name
    var tagName = Document.GetString(Keys.GroupKey);
}

<section>
    <header class="main">
        <h1>Tag: <em>@tagName</em></h1>
    </header>

    <div class="posts">
        @foreach (var post in Document.GetChildren())
        {
            <article>
                @if (post.ContainsKey("Image"))
                {
                    <a href="@post.GetLink()" class="image">
                        <img src="@post.GetString("Image")" alt="" />
                    </a>
                }
                <h3><a href="@post.GetLink()">@post.GetString("Title")</a></h3>
                <p>@post.GetString("Description")</p>
                <ul class="actions">
                    <li><a href="@post.GetLink()" class="button">Read More</a></li>
                </ul>
            </article>
        }
    </div>
</section>

The result is below

And we also can do more, in order to improve SEO, we can automatically generate internal links for articles based on the tags.

Create a ParallelModule in TagAutoLinkModule.cs in project root

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Statiq.Common;

public class TagAutoLinkModule : ParallelModule
{
   protected override async Task<IEnumerable<IDocument>> ExecuteInputAsync(IDocument input, IExecutionContext context)
    {
        // 1. Retrieve all tags and their corresponding URLs from the entire context
        // Assuming your tag page path format is /tags/tag-name
        var allTags = context.Outputs
        .SelectMany(doc => doc.GetList<string>("Tags") ?? Enumerable.Empty<string>()) // Directly use the string "Tags"
        .Distinct()
        .OrderByDescending(t => t.Length)
        .ToDictionary(
            tag => tag,
            tag => $"/tag/{tag.ToLower().Replace(" ", "-")}"
        );

        string content = await input.GetContentStringAsync();

        // 2. Iterate through all tags to process the current article
        foreach (var tag in allTags)
        {
            // Prevent in-article tags from linking to themselves (optional)
            // string pattern = $@"(?<!<[^>]*){tag}(?![^<]*</a>)";

            // Complex Regex: Match tags, but exclude cases where they are already inside <a> tags or HTML attributes
            string pattern = $@"\b({Regex.Escape(tag.Key)})\b(?![^<]*>)(?![^<]*</a>)";

            content = Regex.Replace(content, pattern, $"<a href=\"{tag.Value}\" class=\"internal-tag-link\">$1</a>", RegexOptions.IgnoreCase);
        }

        return input.Clone(context.GetContentProvider(content, "text/html")).Yield();
    }
}

It will find the keywords to match the tags and add the hyperlinks. Process the module in pipline

ModifyPipeline(nameof(Statiq.Web.Pipelines.Content), pipeline =>
{
    // Add custom module to PostModules
    // This ensures it runs after all standard processing (like Markdown rendering) is complete
    pipeline.ProcessModules.Add(new TagAutoLinkModule());
})

And you can find the result below

Auto add the hyperlinks to match the Tags

5.3 Pagination function

For a static website, the pagination is also an issue, but Statiq can help to handle it.

For example, we have a listing page template to show all of the posts in a category in _ListLayout.cshtml

We need to get the posts from input folder

@{
    // 1. Define the variable IEnumerable<IDocument>
    IEnumerable<IDocument> posts;
    // 2. Get the children docs
    var childDocs = Document.GetChildren();
    if (childDocs.Any())
    {
        posts = childDocs;
    }
    else
    {
        posts = OutputPages.GetChildrenOf(Document)
            .Where(x => x.Source.Extension == ".md" && x.Id != Document.Id)
            .OrderByDescending(x => x.Get<DateTime>("Date", DateTime.MinValue));
    }
}
@foreach (var post in posts)
{
    <article>
        @if (post.ContainsKey("Image"))
        {
            <a href="@post.GetLink()" class="image">
                <img src="@post.GetString("Image")" alt="" />
            </a>
        }
        <h3><a href="@post.GetLink()">@post.GetString("Title")</a></h3>
        <p>@post.GetString("Description")</p>
        <ul class="actions">
            <li><a href="@post.GetLink()" class="button">Read More</a></li>
        </ul>
    </article>
}

and handle the pagination logic

@{
    // Get the previouse and next page documents
    var prevPage = Document.GetDocument(Keys.Previous);
    var nextPage = Document.GetDocument(Keys.Next);
    // Get the total pages and current page number
    var totalPages = Document.GetInt(Keys.TotalPages);
    var currentPage = Document.GetInt(Keys.Index);
}
@if (totalPages > 1)
{
    <div class="pagination" style="margin-top: 3rem; text-align: center;">
        @if (prevPage != null)
        {
            <a href="@prevPage.GetLink()" class="button">Prev</a>
        }
        else
        {
            <span class="button disabled">Prev</span>
        }
        <span class="extra" style="margin: 0 1rem;">
            Page @currentPage of @totalPages
        </span>
        @if (nextPage != null)
        {
            <a href="@nextPage.GetLink()" class="button">Next</a>
        }
        else
        {
            <span class="button disabled">Next</span>
        }
    </div>
}

5.4 Archives

The last feature is showing the archives. This is also a common feature for the blog site.

Actually, we just need to get the posts by groping and order then we can get the archives data.

var allPosts = OutputPages
.Where(x => x.Source != null
&& x.Source.ToString().Contains("posts")
&& x.Source.Extension == ".md"
                 && x.ContainsKey("Date"));
var postsByYear = allPosts
.OrderByDescending(x => x.Get<DateTime>("Date"))
.GroupBy(x => x.Get<DateTime>("Date").Year);

Create an archives.cshtml templage to display the data

foreach (var yearGroup in postsByYear)
 {
     <h2 id="year-@yearGroup.Key" style="border-bottom: 2px solid #f56a6a; padding-bottom: 10px; margin-top: 2em;">
         @yearGroup.Key
     </h2>

     <div class="table-wrapper">
         <table>
             <thead>
                 <tr>
                     <th style="width: 120px;">Date</th>
                     <th>Title</th>
                     <th>Category / Tags</th>
                 </tr>
             </thead>
             <tbody>
                 @foreach (var post in yearGroup)
                 {
                     var postDate = post.Get<DateTime>("Date");

                     <tr>
                         <td style="white-space: nowrap; width: 120px;">
                             @postDate.ToString("MMM dd")
                         </td>

                         <td>
                             <a href="@post.GetLink()" style="font-weight: bold;">
                                 @post.GetString("Title")
                             </a>
                         </td>

                         <td>
                             @if (post.ContainsKey("Tags"))
                             {
                                 foreach (var tag in post.GetList<string>("Tags").Take(3))
                                 {
                                     <span class="button small"
                                           style="font-size: 0.6em; height: 2em; line-height: 2em; padding: 0 10px; margin-right: 2px;">
                                         @tag
                                     </span>
                                 }
                             }
                         </td>
                     </tr>
                 }
             </tbody>
         </table>
     </div>
 }

6. Create the content

After creating the theme and feature, we can start to create our content. Statiq will read the content from the input folder, so we must put the content in it. And we can use markdown to create our content.

For example in my website, I put all article in input/posts folder and put the image in input/images folder, and create a post below

---
Title: "The 10 Best Dinnerware Sets of 2026: Trends, Reviews & Buying Guide"
Description: "We tested the top dinnerware sets of 2026. From durable Corelle to luxurious Wedgwood bone china, here are the best plates for every budget and style."
Date: 2026-02-08
Layout: "_PostLayout"
Image: "/images/hero-best-dinnerware-2026.webp"
Tags: [Buying Guide, 2026 Trends, Bone China, Stoneware, Luxury]
---

Other content below ......

You need to set the metdata in the top of the file with markdown format, then the theme will read them.

7. Deployment

Every time you change the content or theme, Statiq will generate all HTML files, so if you just upload all or the updated file to FTP then it will also be an issue.

If your hosting provide SSH and Linux OS, then the below deployment will be perfect for you!

You can use rsync to upload only changed files to your server, and you don’t need to select one by one or check which one should be uploaded

If you are use Mac OS, then the rsync is a built-in command, if you are using Windows, then you need to install the Git Bash or WSL

To check whether your hosting can be support rsync , you can use below commmand

ssh username@your-server.com "rsync --version"

Ok, you can use the script below to deploy your HTML site to the server

#!/bin/bash

# 1. the ssh key 
KEY_PATH="~/.ssh/ssh.key"

# 2. VPS Information
USER="root"      
IP="VPS IP"     
REMOTE_DIR="/opt/www/sites/yourdomain.com/" 

echo "Generating static website..."
dotnet run

echo "Syncing output to server..."

rsync -avz --delete --rsync-path="sudo rsync" -e "ssh -i $KEY_PATH" output/ ${USER}@${IP}:${REMOTE_DIR}

echo "Deployment complete!"

That’s it, so simple! 😁

The full project and theme for you

You can find my full project below

GitHub – coderblog-winson/statiq_theme: A theme for Statiq

A theme for Statiq. Contribute to coderblog-winson/statiq_theme development by creating an account on GitHub.

github.com

8. Conclusion

For me, discovering Statiq was a game-changer. It proved that I didn’t need to learn a new JavaScript framework just to build a fast, secure website. The combination of Statiq’s robust generation pipeline and the lightweight simplicity of Lunr.js created the perfect architecture for my needs.

Whether you are building a portfolio, a company blog, or a documentation site, this stack offers incredible speed and flexibility. I encourage you to clone the repo, tweak the pipelines, and build something of your own. The .NET static site community is growing, and now is the perfect time to jump in.

Loading

Views: 0
Total Views: 89