I photograph weddings and portraits for a living. My job is to be in the room on the best day of someone’s life and quietly keep it, so that twenty years from now they can hand a print to their kids. That is the whole reason chriskelechukwu.com exists: somewhere to show that work, and let the next couple find me.

I am also an engineer. And for years I watched photographer friends lose entire weekends to their own websites. A gallery that took a minute to load. A contact form that dropped half its leads. A “quick” rebrand that meant editing sixty pages by hand. The site was supposed to win them work, and instead it quietly cost them work, and ate the time they should have spent shooting.

So when I finally built my own, I refused the usual trade. A photography site does not have to choose between looking beautiful and loading fast, or between being easy to edit and being safe to edit. You can have all of it. You just have to engineer the thing instead of decorating it. Here is how I built mine, and the four decisions that keep it fast and calm to run.

The trap: a page builder that drifts

I build on a page builder, and I will defend that choice. It lets me lay a gallery out visually, move a section, see the result instantly. The problem is never the builder. The problem is where the builder wants to keep your decisions.

By default, every choice lives on the element that uses it. This heading is 42 pixels here and 40 there. This button is “brand brown,” except in the three places where it is a slightly different brown because I eyeballed the hex on a tired Tuesday. Six months in, there is no brand. There is a pile of one-off settings that happen to look similar. The day you decide to change the accent colour, you are not making one edit, you are hunting through forty pages hoping you found them all.

The fix is to stop letting the builder hold the decisions, and put a real design system underneath it.

One: a design system under the builder

Before I placed a single section, I wrote the system down: a type scale, a spacing scale, a deliberately small palette, a couple of radii, one shadow. Not as notes, as code. CSS custom properties on :root, which become the one place those decisions live.

:root {
  /* Type scale, fluid, one ratio, no per-element sizes */
  --step-0: clamp(1.6rem, 1.5vw, 1.8rem);   /* body */
  --step-1: clamp(2rem, 2.4vw, 2.6rem);     /* lead, large UI */
  --step-2: clamp(2.8rem, 3.6vw, 4rem);     /* section heading */
  --step-3: clamp(4rem, 6vw, 6.4rem);       /* display */

  /* Spacing, a single scale every section pulls from */
  --space-xs: 0.8rem;
  --space-sm: 1.6rem;
  --space-md: 3.2rem;
  --space-lg: 6.4rem;
  --space-xl: 12rem;

  /* Palette, small on purpose. There is no fifth grey. */
  --ink:    #1a1714;
  --paper:  #f6f1ea;
  --accent: #8a6f4e;
  --line:   rgba(26, 23, 20, 0.12);

  --radius: 4px;
  --shadow: 0 1px 2px rgba(0,0,0,.06), 0 10px 28px rgba(0,0,0,.09);
}

That is the entire visual language of the site in one block. Notice what is missing: there is no fifth grey, no second accent, no in-between heading size. That is the point. The constraint is the feature. An editor, including the 1am version of me, literally cannot invent a new shade, because the palette does not contain one.

On a page-builder site you usually cannot ship a full child theme, and editing functions.php is a fine way to white-screen your site at the worst possible moment. So I load the whole framework as one stylesheet through a code-snippets manager (I use Fluent Snippets). One snippet, loaded sitewide, versioned, living outside any single template, so it survives every layout change I make later.

Then I give the builder a small vocabulary that points back at the tokens: a button, a ghost button, a card, a section rhythm. Classes, not inline styles.

.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 1.2rem 2.4rem;
  background: var(--ink);
  color: var(--paper);
  border-radius: var(--radius);
  font-size: var(--step-0);
  letter-spacing: .02em;
}
.btn--ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1px var(--line); }

.card { background: var(--paper); border-radius: var(--radius); box-shadow: var(--shadow); padding: var(--space-md); }
.section { padding-block: var(--space-lg); }

Now the builder does what it is good at, structure and content, and the framework does what it is good at, deciding how things look. The discipline that holds the whole thing together fits in one sentence: if I am typing a hex value into an element setting, I stop and add a token instead. The first time a rebrand took me ten minutes of token edits instead of a week of page-hunting, that discipline paid for itself for the rest of the site’s life.

Two: the content lives in the database, not the layout

The second mistake I see everywhere is content welded into the design. The gallery is the section that displays it. The testimonial lives inside the box it sits in. Editing the words means editing the layout, and one wrong drag breaks the page. That is a terrifying way to run a site, especially for the person who is brilliant with a camera and reasonably wants nothing to do with a CSS grid.

So I modeled the site as what it actually is, before I designed a single screen. A photography site is galleries, collections, and testimonials. Each is a content type with its own typed fields, defined in SCF (Secure Custom Fields). A gallery has a cover, a set of photos, a collection it belongs to, a date, and a featured flag, and it knows nothing about how any page chooses to show it.

// One content type per real thing the site is made of.
add_action('init', function () {
    register_post_type('gallery', [
        'label'       => 'Galleries',
        'public'      => true,
        'has_archive' => true,
        'menu_icon'   => 'dashicons-format-gallery',
        'supports'    => ['title', 'thumbnail'],
        'rewrite'     => ['slug' => 'galleries'],
    ]);
});

// Typed fields, so each one validates and stays queryable.
add_action('acf/init', function () {
    acf_add_local_field_group([
        'key'      => 'group_gallery',
        'title'    => 'Gallery',
        'location' => [[['param' => 'post_type', 'operator' => '==', 'value' => 'gallery']]],
        'fields'   => [
            ['key' => 'field_cover', 'name' => 'cover',       'label' => 'Cover image', 'type' => 'image', 'return_format' => 'id'],
            ['key' => 'field_imgs',  'name' => 'images',      'label' => 'Photos',      'type' => 'gallery'],
            ['key' => 'field_coll',  'name' => 'collection',  'label' => 'Collection',  'type' => 'post_object', 'post_type' => ['collection']],
            ['key' => 'field_date',  'name' => 'shot_on',     'label' => 'Shot on',     'type' => 'date_picker'],
            ['key' => 'field_feat',  'name' => 'is_featured', 'label' => 'Featured',    'type' => 'true_false'],
        ],
    ]);
});

Typed fields are the quiet hero here. An image field only takes an image. A relationship field links a gallery to a collection and keeps that link queryable. A true/false is a real boolean, not the word “yes” typed five different ways. The model validates itself, and it documents itself: anyone who opens the editor can see exactly what a gallery is made of.

Because the model is typed, the front end gets to ask real questions of it. The “featured work” strip on my homepage is not a section I curate by hand, it is a query.

// The homepage "featured work" strip is a query, not a section I hand-curate.
$featured = new WP_Query([
    'post_type'      => 'gallery',
    'posts_per_page' => 6,
    'meta_key'       => 'is_featured',
    'meta_value'     => '1',
    'orderby'        => 'date',
    'order'          => 'DESC',
]);
// Flag a gallery as featured in the editor and it surfaces.
// Unflag it and the next one takes its place. No layout work either way.

The page-builder templates read from those fields with dynamic bindings. The template is the frame, defined once, and every gallery flows through it. I add a gallery in the backend, fill in labelled fields, hit save, and it appears wherever galleries are shown, in the right style, with zero layout work. Editing content never touches design. Editing design never risks content. That separation is the difference between a site I dread updating and one I update from my phone between shoots.

Three: an inquiry flow that runs itself

A photographer who answers a wedding inquiry a day late has usually already lost it. The couple messaged five of us. Whoever replied first, and cleanest, is in the lead. When capturing and routing that inquiry depends on me remembering to check an inbox, the gap is invisible right up until a booking quietly goes to someone else.

So I built the inquiry flow to run without me. I start from what I need to know to say yes, and I ask for it in structured fields, not one open message box. A wedding date request and a general session inquiry are different conversations, so they are different forms, with date pickers and conditional logic so each form only asks what is relevant. Structured answers are routable and reportable. A wall of free text is neither.

Then Fluent Forms routes every submission the moment it lands.

// Every wedding inquiry, routed the moment it lands, no inbox-watching.
add_action('fluentform/submission_inserted', function ($entryId, $formData, $form) {
    if ($form->id != 3) {            // the wedding inquiry form
        return;
    }

    // Structured fields, not a wall of free text.
    $lead = [
        'name'   => $formData['names']        ?? '',
        'email'  => $formData['email']        ?? '',
        'date'   => $formData['wedding_date']  ?? '',
        'venue'  => $formData['venue']         ?? '',
        'budget' => $formData['budget_range']  ?? '',
    ];

    crm_upsert_lead($lead);                            // 1. lead exists outside my inbox
    calendar_pencil_in($lead['date'], $lead['name']);  // 2. hold the date provisionally
}, 20, 3);

The important word is “every.” The routing is a rule, not a habit. A wedding inquiry pushes a lead into the CRM and pencils the date onto my calendar before I have even seen the email. The client gets an instant, warm confirmation that buys me time and sets expectations. And critically, every inquiry is stored as a record in the database, independent of email, so even if a notification goes missing, the lead is still caught and still visible. I went from chasing leads to handling them.

Four: fast is a pipeline, not a setting

Here is the tension at the heart of a photography site. The entire point is large, beautiful images, and large images are heavy. A gallery of them can turn a single page into a multi-megabyte download, and on a builder that ships its own scripts and styles, you end up with a site that looks stunning in a screenshot and loads like a brochure from 2009. Slow pages cost bookings, quietly, the same way a slow inbox does.

You do not fix this by using smaller photos. I refuse to show my work at thumbnail quality. You fix it by building a pipeline that serves the right image, at the right size, in the right format, only when it is actually needed.

Most of the weight is in the originals, so that is where the first and biggest win lives. I serve modern formats (WebP, AVIF), generate correctly sized variants instead of shrinking a huge file in the browser, and hand the browser a srcset so it picks the smallest file that fits the slot.

// Generate sensible variants once, on upload.
add_image_size('gallery_sm', 800, 0);
add_image_size('gallery_md', 1400, 0);
add_image_size('gallery_lg', 2000, 0);

// Hand the browser a srcset and let it pick the smallest file that fits.
function gallery_image($attachment_id) {
    return wp_get_attachment_image($attachment_id, 'gallery_lg', false, [
        'loading'  => 'lazy',        // below-the-fold streams in on scroll
        'decoding' => 'async',
        'sizes'    => '(max-width: 800px) 100vw, 800px',
    ]);
    // WordPress emits the srcset of the generated sizes automatically.
}

Then everything below the fold loads lazily, so the first paint only fetches what the visitor can actually see, and the rest streams in as they scroll. A gallery page does not change between visits, so it should not be rebuilt on every visit: full-page caching serves a ready-made page instantly, an object cache stops repeated database work piling up, and a CDN puts the heavy assets close to the visitor instead of making everyone round-trip to the origin.

The last piece is restraint. On image-heavy sites the slow creep is almost always plugins, each one adding scripts and styles to every page whether that page needs them or not. I keep the footprint lean and audit what every active plugin actually costs in bytes. A fast gallery site is as much about what I left out as what I put in.

Collections, and letting the structure carry the navigation

There is a second content type I have not mentioned yet, and it is the one that makes the site feel considered rather than just fast: the collection. A collection is a body of related work, a wedding season, a city, a particular style, and every gallery belongs to one. Because that relationship is a real, typed field and not a tag I hoped I remembered to add, the site can navigate itself.

The collection page is not something I build and then maintain by hand. It is a query. Open a collection and it pulls every gallery pointing at it, in the order I chose, each with the right cover image, automatically.

// A collection page is just its galleries, asked for by relationship.
$galleries = new WP_Query([
    'post_type'      => 'gallery',
    'posts_per_page' => -1,
    'meta_query'     => [[
        'key'     => 'collection',
        'value'   => get_the_ID(),   // the current collection
        'compare' => '=',
    ]],
    'orderby' => 'menu_order date',
    'order'   => 'ASC',
]);

When I shoot a new wedding, I create one gallery, point it at the right collection, and decide whether it is featured. That single editorial act puts it on the collection page, potentially on the homepage, and into the site’s internal linking, with no layout work and nothing for me to remember a week later. The structure carries the navigation, so I do not have to. That is the quiet luxury of modeling content properly: the site keeps getting more useful as I add to it, and I never pay for that in maintenance.

How I know it is actually fast

Fast is a claim, and I do not trust claims I cannot see on a graph. So I watch the numbers a visitor actually feels, not a synthetic score that makes me feel good for an afternoon.

The one I care about most on a photography site is Largest Contentful Paint, because on nearly every page the largest element is an image, usually the hero. If my pipeline is doing its job, that hero is a correctly sized, modern-format file the browser was told about early, so it paints quickly even on a phone with one bar of signal. I learned how much that matters the day I broke it: I lazy-loaded the hero by accident, LCP fell off a cliff, and the fix was a single attribute.

// The hero is the LCP element, so it must NOT be lazy. Everything below it is.
function hero_image($attachment_id) {
    return wp_get_attachment_image($attachment_id, 'gallery_lg', false, [
        'loading'       => 'eager',
        'fetchpriority' => 'high',   // tell the browser this one matters
        'decoding'      => 'async',
        'sizes'         => '100vw',
    ]);
}

The rest is unglamorous and continuous. I watch real-user Core Web Vitals rather than one lab test on my fast laptop. I re-check the page weight every time I add anything, because weight creeps in one plugin at a time. And I treat a slow page as a bug to fix, not a tax to accept. A photography site earns trust in the first second, the same way I do when I walk into a room with a camera and people decide, before I have said much, whether they feel at ease. If the first image is slow, the visitor has already started to wonder whether I am the right choice. I do not give them the chance to.

Boring on purpose

One more part, because it is the one nobody photographs. A site you rely on for income has to be boring in the ways that count. Mine has automated daily backups that I have actually tested by restoring, a staging copy where every change gets tried before it touches the live site, and updates I apply on a schedule instead of in a panic. None of that shows up in a portfolio. All of it is why I have never lost a booking to a broken website.

I learned that the way most people do, by getting burned once. Early on I made a “small” change directly on the live site between two shoots, broke the gallery template, and spent an evening I did not have putting it back while inquiries sat unanswered in a form I could not see. Now the rule is simple, and I hold it like a religion: the live site is not where I experiment. It is where the work lives. Experiments happen on staging, and only a change I have already watched working gets promoted. Calm is not luck. It is a setup you decide on once, when nothing is on fire, so that later, when something is, there is nothing to fight.

What it bought me

The site has run itself for a while now. Couples find a gallery, feel something, and send a structured inquiry that lands in my CRM and on my calendar before I have put my camera down. The pages stay fast on a phone, at a venue, on one bar of signal. And when I evolve the brand, I edit a handful of tokens and the whole site moves at once.

None of this is exotic. It is the same instinct I bring to a wedding: do the unglamorous preparation up front, so that when it matters you are present instead of fighting your gear. A site you decorate fights you harder every month. A site you engineer gets easier to run as it grows. I would rather spend that time behind the camera, documenting the next happy memory, which was the whole point of the site in the first place.

If you are running a site that has started to fight you, that is the kind of work I do for other people too, through BespokeForge. But honestly, even if you build it entirely yourself, build it like it has to last. Your future self, the one who would rather be shooting, will thank you.