SEO and Performance

How I built the SEO and performance infrastructure of Lok Chan Art, including multilingual metadata, canonical URLs, structured data, indexing controls, and production Lighthouse audits.

Published:

SEO and performance are handled as part of the application architecture rather than as a separate layer added after development.

Lok Chan Art is a multilingual production application, so search metadata, localized routing, indexing rules, structured data, image delivery, and performance all need to work together.

Rather than maintaining SEO configuration manually for individual URLs, I built most of the infrastructure around shared application utilities and content-aware generation.

Multilingual SEO 🌐

The application uses next-intl with locale-prefixed routes.

It currently supports ten locales:

Text
en
de
it
es
fr
ru
uk
lv
pl
lt

English is the default locale, but the locale prefix is always present in public URLs.

For example:

/en/commissions /de/commissions /fr/commissions

The language of a page is therefore explicit in the route itself rather than existing only in browser state or cookies.

Each public page generates its own localized title and description while shared URL and social metadata construction is handled by buildPageMetadata.

For example, the Commissions page provides translated values to the shared metadata builder:

TypeScript
  const baseMetaData = buildPageMetadata({
    locale: paramsObj.locale,
    path: '/commissions',
    title: t('commissionsTitle'),
    description: t('commissionsDescription'),
    ogImageAlt: t('ogImageAlt'),
  });

Page-specific metadata can still add properties that are relevant only to that route, such as localized commission keywords.

This keeps the implementation shared without forcing every page to have exactly the same metadata.

Canonical URLs and alternate languages

Canonical URLs are generated from the current production domain, locale, and normalized route path.

For example, the English Commissions page receives:

Text
https://lokchanart.com/en/commissions

as its canonical URL.

The same metadata utility generates hreflang alternate URLs.

Static application pages exist in every supported locale, so their alternates can be generated from the complete locale configuration.

MDX content requires a different approach.

Engineering and Journal articles are stored independently for each locale, and an article is not required to have translations in all ten languages.

Engineering content follows routes such as:

Text
/en/engineering/overview
/en/engineering/domain-migration
/en/engineering/seo-performance

overview is treated like any other Engineering article slug.

Before generating alternate-language metadata for an MDX article, the application checks which locale files actually exist. Only those locales are passed to the shared metadata builder.

This prevents a URL such as:

Text
/ru/engineering/domain-migration

from being advertised as a Russian translation when there is no Russian MDX source for the article.

If a visitor explicitly switches to an unavailable language, the application can still keep the logical article URL and display the languages in which the article is available.

That state is useful for the visitor, but it is not treated as another translation of the article.

The unavailable-language page is marked:

Text
noindex, follow

in production, while preview environments remain:

Text
noindex, nofollow

This keeps the user experience separate from the SEO representation of the content.

Social metadata

The same metadata pipeline also generates Open Graph and Twitter metadata.

Open Graph metadata includes the localized title and description, canonical page URL, site name, locale, and a 1200 x 630 preview image.

Twitter metadata uses a large-image card with the corresponding page title, description, and image.

Keeping these values in the same builder means that canonical metadata and social previews are derived from the same page-level configuration rather than being maintained independently.

For development, I also added an optional Open Graph testing mode that can use a temporary ngrok URL instead of the production domain.

This makes it possible to inspect a locally running page with external preview tools such as the LinkedIn Post Inspector without modifying the real production domain configuration.

Social previews were also verified against external crawlers rather than relying only on locally generated metadata.

LinkedIn Post Inspector showing the Open Graph preview generated for Lok Chan Art
Open Graph metadata verified with LinkedIn Post Inspector against the production domain.

Structured data

The application includes JSON-LD structured data at both global and page-specific levels.

At the root layout, I define separate Person and WebSite entities.

JSON
{
  "@type": "Person",
  "@id": "https://lokchanart.com#person",
  "name": "Tkhiien Lok Chan",
  "url": "https://lokchanart.com"
}

The website is represented as a separate entity:

JSON
{
  "@type": "WebSite",
  "@id": "https://lokchanart.com#website",
  "name": "Lok Chan Art",
  "url": "https://lokchanart.com",
  "publisher": {
    "@id": "https://lokchanart.com#person"
  }
}

The stable @id values make the relationship between the artist and the website explicit without representing them as the same entity.

The Person schema also links to the public Instagram profile using sameAs.

Pages can add more specific structured data where there is a corresponding real-world concept to describe.

The Commissions page, for example, adds a localized Service schema containing the service name, description, provider, service type, and European service area.

I prefer adding schemas where they describe actual entities or services rather than attaching additional schema types simply to increase the amount of structured data on the page.

Sitemap and indexing

The sitemap is generated programmatically by the application rather than being maintained as a static XML file.

Static localized routes include:

Text
/
/gallery
/store
/commissions
/contact
/journal

Each of these routes is expanded across the supported locale configuration.

Journal and Engineering URLs are generated differently.

For each locale, the application reads the MDX posts that actually exist and adds only those posts to the sitemap.

For example:

Text
/en/engineering/overview
/en/engineering/domain-migration
/en/engineering/seo-performance

can exist without corresponding German, Russian, or French URLs being inserted into the sitemap.

The same principle applies to Journal content.

Article dates from MDX frontmatter are used as lastModified values, while static routes use broader update frequencies.

This allows content creation and sitemap generation to stay connected to the same filesystem source of truth.

Robots and non-production indexing

Production robots.txt allows normal public pages to be crawled and exposes the generated sitemap.

Application routes that should not appear in search results are excluded, including administration, settings, API routes, and checkout completion pages.

Indexing can also be disabled at deployment level using:

Text
NEXT_PUBLIC_NO_INDEX=true

When this flag is enabled, indexing protection is applied at multiple layers.

Page metadata can return:

Text
noindex, nofollow

The generated robots.txt disallows crawling, and the Next.js configuration adds:

Text
X-Robots-Tag: noindex, nofollow

to responses.

I used this approach for preview and migration environments so the complete application could be tested on a real deployment without allowing that deployment to become a second indexed version of the website.

The same application code can therefore run in both preview and production while indexing behaviour remains an environment-level concern.

SEO during the domain migration

SEO infrastructure also played an important role when the project moved from Sunny Day Art to Lok Chan Art.

The new application domain was first deployed and tested with indexing disabled.

After production validation, metadata, canonical URLs, structured data, sitemap configuration, and application environment variables were switched to:

Text
https://lokchanart.com

The previous domains remain connected through permanent host-based redirects.

For example:

Text
https://sunnydayart.com/en/commissions
                    ↓
https://lokchanart.com/en/commissions

The original path is preserved instead of redirecting every old URL to the new homepage.

The same redirect behaviour applies to the previous www hostname.

This preserves the relationship between old and new resources during a domain change while keeping redirect logic inside the application configuration.

The broader migration process, including payments, transactional email, storage, deployment protection, and production rollout, is covered separately in the

domain migration case study.

Performance

Performance work focuses primarily on controlling what the browser needs to load and avoiding infrastructure that is not yet justified by the scale of the application.

Images are particularly important because artwork is the main content of the site.

The application uses Next.js Image, with both WebP and AVIF enabled:

TypeScript
images: {
  formats: ['image/webp', 'image/avif'],
  minimumCacheTTL: 60 * 60 * 24 * 30,
  qualities: [65, 75],
},

The image configuration uses a limited set of quality levels and a long minimum cache TTL.

Image components provide their intrinsic width and height so the browser can reserve layout space before the image has finished loading.

Responsive sizes values also allow the browser to request an image that better matches the rendered layout.

For gallery images, the current sizing rule is:

Text
(max-width: 767px) 50vw,
(max-width: 1279px) 33vw,
25vw

This corresponds to the number of gallery columns used at different viewport sizes.

The gallery also treats initially visible images differently from images further down the page.

The first six gallery items use eager loading and high fetch priority:

Text
loading={
  itemIndex < PRIORITIZED_LOADING_IMAGE_AMOUNT
    ? 'eager'
    : 'lazy'
}
 
fetchPriority={
itemIndex < PRIORITIZED_LOADING_IMAGE_AMOUNT
? 'high'
: 'low'
}
 

Later images use lazy loading and lower fetch priority.

For an image-focused website, the goal is not to minimize the number of images at all costs. The more useful optimization is to prioritize the images that are most likely to contribute to the initial viewport while postponing work that the visitor cannot yet see.

Avoiding unnecessary infrastructure

I currently do not maintain an additional dedicated image CDN layer for the project.

At the current scale, Next.js image optimization, responsive image sizing, browser caching, and the hosting platform already provide the delivery behaviour the site needs.

Adding another infrastructure layer would introduce additional configuration and operational complexity without solving a measured problem.

That is a deliberate current-state decision rather than a permanent architectural limitation.

If traffic, image volume, storage requirements, or geographic distribution grow enough to make another delivery layer useful, the architecture can evolve without changing the content model.

Production Lighthouse audits

I use Lighthouse against the deployed production application rather than treating localhost results as the primary performance evidence.

A production audit includes the real generated application assets, production image optimization, deployed JavaScript bundles, server response behaviour, and network conditions used by the Lighthouse profile.

I also keep the complete HTML reports rather than displaying only score badges.

This makes the underlying audit available for inspection and preserves the individual metrics and diagnostics behind the overall score.

Homepage — Desktop

The production desktop audit of the English homepage produced:

Text
Performance        100
Accessibility       96
Best Practices     100
SEO                100
 
First Contentful Paint     0.4 s
Largest Contentful Paint   0.7 s
Speed Index                0.7 s
Total Blocking Time        0 ms
Cumulative Layout Shift    0
Production desktop audit of the English homepage, generated on September 6, 2026. Open full report

Commissions — Desktop

I audit the Commissions route separately because it represents a more application-oriented page than the homepage.

The production desktop report produced:

Text
Performance         99
Accessibility       95
Best Practices     100
SEO                100
 
First Contentful Paint 0.3 s
Largest Contentful Paint 0.8 s
Speed Index 0.4 s
Total Blocking Time 0 ms
Cumulative Layout Shift 0
 
Production desktop audit of the English Commissions page, generated on August 23, 2026. Open full report

Commissions — Mobile

Mobile Lighthouse testing uses a more constrained environment than the desktop profile, so I keep it as a separate audit rather than assuming that a strong desktop result represents mobile performance.

The production mobile report produced:

Text
Performance         99
Accessibility       95
Best Practices     100
SEO                100
 
First Contentful Paint     1.1 s
Largest Contentful Paint   1.9 s
Speed Index                2.0 s
Total Blocking Time        20 ms
Cumulative Layout Shift    0
Production mobile audit of the English Commissions page, generated on August 23, 2026. Open full report

Reading the reports beyond the score

A high Lighthouse score is useful, but I do not treat it as proof that a page has no remaining performance or accessibility work.

For example, the current reports still contain diagnostics around areas such as unused JavaScript and image delivery.

These recommendations are more useful as an optimization backlog than the aggregate score alone.

The desktop Commissions report shows approximately 98 KiB of potentially unused JavaScript, while the audit also identifies possible image-delivery savings.

At the same time, the audited pages currently show a Cumulative Layout Shift of 0, which is particularly relevant on pages where images are a major part of the visual layout.

The Accessibility scores are also shown together with the 100-point SEO and Best Practices results rather than being omitted.

Keeping the complete reports visible makes both the successful checks and the remaining issues part of the engineering record.

Extending the audit set

The current reports cover a lightweight public entry page and the more functional Commissions flow across desktop and mobile.

A useful next addition is the Gallery because its workload is different again: it contains a larger number of artwork images and therefore provides a better test of responsive image delivery and prioritization.

This is more meaningful than adding audit reports simply to make the number of desktop and mobile examples symmetrical.

As the application changes, I can repeat the same production audits and compare the underlying metrics rather than treating the current values as permanent performance claims.