If you have already shipped a basic Pelican site, the next problem is rarely deployment. The harder part is what happens a few weeks later, when the default theme no longer matches the site you are actually trying to build.

That is where many Pelican sites start to feel awkward. A few template overrides become a pile of overrides. Routing rules leak into Jinja templates. CSS grows as page-specific patches. Publishing still works, but the codebase feels harder to reason about every time you add a new section or refresh the design.

This tutorial is about getting out of that stage. We will turn a Pelican site from “a default theme with a lot of overrides” into a custom theme that is easier to extend, maintain, and publish. The goal is to end up with:

  • clearer routing based on content structure instead of template guesswork
  • shared templates that are easier to extend
  • reusable CSS layers instead of one stylesheet per page idea
  • production settings that stay boring and predictable

If you have not set up a Pelican project yet, start with Deploying Your First Pelican Site with VSCode and Cloudflare Pages. This guide assumes your site already builds and deploys, and focuses on making it easier to live with long term.

The Problem With Endless Overrides

The first version of a Pelican site often starts with a stock theme plus a few overrides. That works well at the beginning, but it often leads to templates that mix too many concerns together.

For example, you might end up with page templates that decide which posts belong in which section:

{% set project_articles = articles | selectattr("category", "equalto", "Projects") | sort(attribute="date", reverse=True) | list %}
{% set writing_articles = articles | rejectattr("category", "equalto", "Projects") | sort(attribute="date", reverse=True) | list %}
{% set tutorial_articles = writing_articles | selectattr("category", "equalto", "Tech") | list %}

That works for a first pass, but it creates hidden coupling:

  • routing depends on category names instead of file structure
  • changing a content section means touching multiple templates
  • each hub page slowly grows its own filtering logic
  • design changes get mixed together with content rules

CSS often follows the same pattern. A few quick fixes turn into several page-specific stylesheets, and soon it becomes hard to tell which rules are foundational and which ones are just exceptions.

The fix is not to “override better.” The fix is to stop treating the theme as someone else’s theme and turn it into a first-class part of your Pelican project.

Step 1: Build A Real Custom Theme

Instead of piling more overrides onto a stock theme, create a dedicated theme/ directory that you control end to end:

theme/
├── static/
│   ├── css/
│   └── js/
└── templates/
    ├── partials/
    ├── macros/
    └── redirects/

Then point Pelican at it directly:

THEME = "theme"
THEME_STATIC_DIR = "static"

Once you own the theme, your base template can describe your site instead of the assumptions of a third-party design.

A good base.html should usually do three things:

  • set metadata through simple variables such as meta_title and meta_description
  • load shared CSS layers in a fixed order
  • expose small extension points like page_css, extra_head, and content

Here is a simple example:

<!DOCTYPE html>
<html lang="{{ DEFAULT_LANG }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {% set meta_title = meta_title | default(SITENAME) %}
    {% set meta_description = meta_description | default(SITESUBTITLE or SITENAME) %}
    <title>{{ meta_title }}</title>
    <meta name="description" content="{{ meta_description }}">
    <link rel="stylesheet" href="{{ SITEURL }}/static/css/tokens.css">
    <link rel="stylesheet" href="{{ SITEURL }}/static/css/base.css">
    <link rel="stylesheet" href="{{ SITEURL }}/static/css/layout.css">
    <link rel="stylesheet" href="{{ SITEURL }}/static/css/components.css">
    {% block page_css %}{% endblock %}
</head>
<body>
    {% include "partials/navigation.html" %}
    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

That already gives you a cleaner foundation than repeating title, canonical, and stylesheet blocks across several page templates.

Step 2: Let Content Structure Drive Routing

One of the biggest improvements you can make is to move section logic into configuration and content paths.

Instead of asking templates to infer whether an article belongs to tutorials, projects, or writing, make the directory structure the source of truth:

ARTICLE_PATHS = ["projects", "tutorials", "writing"]
PAGE_PATHS = ["pages"]
PATH_METADATA = r"(?P<section>[^/]+)/(?P<slug>[^/.]+)\..*"

ARTICLE_URL = "{section}/{slug}/"
ARTICLE_SAVE_AS = "{section}/{slug}/index.html"
PAGE_URL = "{slug}/"
PAGE_SAVE_AS = "{slug}/index.html"

This does a lot of work for very little code:

  • a file in content/tutorials/ automatically belongs to the tutorials section
  • article URLs become predictable
  • the output matches the final URL structure
  • section-aware templates can rely on article.section

That single move makes the site much easier to reason about than category-driven template filters.

It also makes section hub pages much simpler. Instead of writing different filtering logic in every template, create one helper and one shared section template.

For example, a small helper like this keeps the Jinja small:

def _resolve_section(item):
    section = getattr(item, "section", None)
    if section:
        return str(section).lower()

    url = getattr(item, "url", "") or ""
    return url.split("/", 1)[0].lower() if "/" in url else ""


def in_section(items, section_name):
    target = section_name.lower()
    return [item for item in items if _resolve_section(item) == target]

Register it in your settings:

JINJA_FILTERS = {
    "in_section": in_section,
}

Then a section page such as templates/tutorials.html only needs to provide copy and the section name:

{% extends "section.html" %}
{% set section_key = "tutorials" %}
{% set section_label_text = "Tutorials" %}
{% set section_articles = articles | in_section("tutorials") | list %}

That is a much cleaner split of responsibilities:

  • Pelican config decides URLs and section metadata
  • helper functions handle content-aware logic once
  • templates focus on rendering

When you want to add a new section later, you are no longer reverse-engineering several old templates to remember how the site decides what belongs where.

Step 3: Use Shared Templates For Repeated Page Shapes

Once routing is clearer, the next job is reducing repeated template markup.

Most Pelican sites eventually repeat the same pieces in several places: a hero section, article cards, metadata rows, sidebars, section intros, and footer blocks. Rather than duplicating that structure everywhere, split the templates into a few predictable layers:

  • base.html for the site frame, metadata defaults, and asset loading
  • section.html for shared section landing pages
  • article.html for post pages with hero, content, and table of contents
  • page.html for evergreen pages like About and Contact
  • macros/cards.html for reusable article cards
  • partials/ for navigation and footer

This is especially helpful for section pages. A generic section.html can handle the featured article area and the article list once, so individual section templates only need to set labels and intro text.

That keeps changes local. If you redesign the section hero or card grid, you update one template instead of three near-copies.

It is also worth moving content-specific transforms into Python helpers rather than building them inline in Jinja. For example, helpers can:

  • normalize_article_content() remove duplicate top-level headings and leading horizontal rules
  • add_ids_to_headings() add stable heading IDs
  • build_toc() generate a table of contents from article headings
  • section_label() convert internal section names into display labels

That makes article.html easier to read because it can work with prepared values:

{% set article_html = article.content | normalize_article_content(article.title) | add_ids_to_headings %}
{% set toc_html = article_html | build_toc %}
{% set article_section_label = article.section | section_label %}

Jinja is still useful here, but it is no longer doing all the thinking.

The general rule is simple: if a template needs to do the same non-trivial transformation more than once, move that logic into a helper.

Step 4: Organize CSS Into Layers

The CSS refactor matters just as much as the template refactor.

What helps most is separating styles by responsibility instead of by page history. A practical structure looks like this:

theme/static/css/
├── tokens.css
├── base.css
├── layout.css
├── components.css
├── utilities.css
├── syntax.css
└── pages/
    ├── content.css
    ├── home.css
    └── section.css

Each file has a clear job:

  • tokens.css defines design variables such as colors, spacing, shadows, radii, and theme values
  • base.css handles element defaults like body, headings, text, links, and images
  • layout.css defines shared page structure such as containers, grids, sections, and responsive layout rules
  • components.css styles reusable interface pieces like buttons, panels, cards, navigation, tags, and footer blocks
  • utilities.css is reserved for small helpers
  • pages/*.css contains only the styles that are truly specific to one page family

This is a better fit for a growing site because design choices start to accumulate as reusable primitives. For example:

  • color changes happen in one place
  • a spacing change in a shared card does not require editing several page stylesheets
  • a new section page can reuse existing layout and component classes
  • page-specific CSS stays small because most of the work is already handled

The exact filenames can change. The important part is drawing a line between system styles and exceptions.

Step 5: Make Publishing Boring

The final piece is making sure the nicer structure does not create a more fragile deployment process.

One of the easiest ways to do that is to keep publishing logic in a small base-and-override setup:

# pelicanconf.py
SITEURL = ""
RELATIVE_URLS = False

# publishconf.py
SITEURL = "https://example.com"
RELATIVE_URLS = False
FEED_ALL_ATOM = "feeds/all.atom.xml"

That split keeps local development and production close to each other, while still giving production the few settings it actually needs.

For day-to-day work, keep the commands simple. If you are using invoke, they might look like this:

uv sync
uv run invoke build
uv run invoke serve
uv run invoke preview

If you are not using invoke, the same idea still applies:

pelican content -s pelicanconf.py
pelican content -s publishconf.py

That workflow is easier to keep healthy than a custom deployment script hidden inside the theme. It also means you can verify the production version locally before pushing anything.

Two smaller details help a lot as the site evolves:

  • DELETE_OUTPUT_DIRECTORY = True keeps old generated files from hanging around after URL or template changes
  • TEMPLATE_PAGES can preserve legacy URLs while the new structure settles in

For example:

TEMPLATE_PAGES = {
    "redirects/old-blog.html": "blog/index.html",
    "redirects/old-about.html": "pages/about.html",
}

That redirect layer is especially useful if you are moving from older paths like blog/... or legacy .html pages to cleaner section-based URLs. It lets you improve the architecture without breaking inbound links.

What This Refactor Changes In Practice

After this kind of refactor, adding new content becomes much less dramatic.

If you add a tutorial, you should know:

  • it belongs in content/tutorials/
  • it will publish under /tutorials/<slug>/
  • it will appear in the tutorials hub without extra template logic
  • it will inherit the same cards, metadata, navigation, and content styling as the rest of the site

If you want to redesign the site, you should know where to look:

  • tokens for color and theme changes
  • layout for grid and spacing changes
  • components for reusable UI pieces
  • page styles only when the design is truly page-specific

That clarity is the real payoff. The site is not just more custom. It is easier to extend without having to remember old theme assumptions first.

A Good Stopping Point

You do not need to rebuild your Pelican site all at once to get these benefits.

If your current setup feels messy, tackle it in this order:

  1. Move to a real theme/ directory you own.
  2. Let content paths define sections and URLs.
  3. Pull repeated page markup into shared templates and macros.
  4. Split CSS into tokens, base, layout, components, and page-specific layers.
  5. Keep publishing settings minimal and separate from design concerns.

That sequence turns Pelican from “a static site generator with some overrides” into a maintainable site architecture.

Pelican is still doing what it does best: taking content and turning it into static files. The difference is that your theme, routing, and styling now support the shape of your site instead of constantly fighting it.

About Me

Abhyuday Singh

I build products that mix software, data, automation, UI/UX, and AI, and I like writing about what I learn along the way.

Get in touch