Struktura adresów URL
Ta strona opisuje, jak powstają adresy URL treści w Twoim storefroncie — w dwóch warstwach:
- Domyślna konwencja adresów — gotowy zestaw segmentów ścieżek. Trzymaj się go, a linki, które właściciel sklepu widzi w panelu jako podgląd treści, od razu trafią do stron Twojego storefrontu.
- Deklaracja własnej konwencji — jeśli Twój storefront ma inny routing (np. kategorie bezpośrednio pod adresem głównym), deklarujesz własne szablony adresów w ustawieniach sklepu, a wtedy podgląd w panelu, feed produktowy Google i mapa witryny automatycznie mówią Twoją konwencją.
W obu przypadkach GraphQL API wydaje dla każdej treści tylko handle (slug) — pełną ścieżkę składasz sam w routerze swojej aplikacji.
Domyślna konwencja adresów
Domyślnie platforma zakłada poniższe segmenty ścieżek (na przykładzie sklepu pod domeną twoj-sklep.pl). Znacznik {slug} to uchwyt treści (handle), który wydaje GraphQL API:
| Typ treści | Segment ścieżki | Przykładowy adres |
|---|---|---|
| Produkt | /products/ | twoj-sklep.pl/products/{slug} |
| Kategoria produktu | /categories/ | twoj-sklep.pl/categories/{slug} |
| Marka | /brands/ | twoj-sklep.pl/brands/{slug} |
| Kolekcja | /collections/ | twoj-sklep.pl/collections/{slug} |
| Strona treści (CMS) | /pages/ | twoj-sklep.pl/pages/{slug} |
| Artykuł bloga | /blog/ | twoj-sklep.pl/blog/{slug} |
| Kategoria bloga | /blog/category/ | twoj-sklep.pl/blog/category/{slug} |
| Tag bloga | /blog/tag/ | twoj-sklep.pl/blog/tag/{slug} |
Segmenty są angielskie niezależnie od języka sklepu — to spójna, przewidywalna baza. Lokalizację segmentów (np. /kategoria/ zamiast /categories/) obsługujesz osobno: w routerze storefrontu albo deklaracją własnych szablonów (patrz Wielojęzyczność).
Panel administracyjny pokazuje właścicielowi sklepu dokładnie te adresy jako podgląd treści (np. przycisk „Podgląd" przy produkcie). Jeśli Twój storefront trzyma się tych segmentów, linki po prostu działają. Jeśli używasz innych — zadeklaruj je w szablonach sklepu (sekcja niżej), aby panel znał Twoją konwencję i nie prowadził do stron 404.
Deklaracja własnej konwencji adresów
Domyślna konwencja to punkt wyjścia, nie przymus. Jeśli Twój storefront routuje treści inaczej — na przykład serwuje kategorie bezpośrednio pod adresem głównym (twoj-sklep.pl/{slug}) zamiast pod /categories/ — możesz zadeklarować własne szablony adresów w ustawieniach sklepu (pole urlTemplates, ustawiane przez API aktualizacji sklepu). Deklarujesz szablon raz, a wszystkie platformowe artefakty, które muszą wydrukować pełny adres treści na Twojej domenie, natychmiast używają Twojej konwencji:
- linki „Podgląd" w panelu właściciela sklepu,
- feed produktowy Google (
g:link), - mapa witryny (
sitemap.xml).
Deklarujesz tylko te zasoby, które routujesz inaczej — dla pominiętych obowiązuje domyślna konwencja z tabeli wyżej.
Zasoby, których Twój storefront nie routuje
Deklaracja zna też trzeci stan: null przy zasobie oznacza „mój storefront nie ma tej trasy". Wtedy platforma przestaje drukować adresy tego zasobu — znika akcja „Podgląd" w panelu, a mapa witryny pomija całą sekcję. Bez tej deklaracji platforma emitowałaby adresy prowadzące do błędu 404, co szkodzi pozycji sklepu w wyszukiwarkach.
Trzy zasoby są domyślnie oznaczone jako nieroutowane: strony treści (page) oraz kategorie i tagi bloga (blogCategory, blogTag) — to trasy, które budujesz samodzielnie (patrz sekcje wyżej), więc platforma nie zakłada ich istnienia. Jeśli Twój storefront je serwuje, włącz je jawną deklaracją szablonu (może być identyczny z domyślnym), np.:
{
"page": "/pages/{slug}",
"blogCategory": "/blog/category/{slug}",
"brand": null
}
Powyższa deklaracja mówi: „mam strony treści i kategorie bloga pod domyślnymi ścieżkami, ale nie mam stron marek".
Gramatyka szablonu
Szablon to wzorzec ścieżki z jednym miejscem na uchwyt treści. Reguły są celowo wąskie — platforma odrzuci szablon, który je łamie (błąd walidacji przy zapisie):
- zaczyna się od
/— to ścieżka absolutna od korzenia domeny, - dokładnie jeden znacznik
{slug}— w to miejsce wpada uchwyt treści (percent-encodowany przy składaniu adresu), - co najwyżej jeden opcjonalny znacznik
{locale}— jeśli go nie wpiszesz, a treść jest w języku innym niż domyślny język sklepu, ścieżka dostanie prefiks/{locale}(tryb „as-needed": język domyślny nie ma prefiksu). Wstaw{locale}jawnie, gdy chcesz umieścić kod języka w konkretnym miejscu ścieżki, - tylko znaki ścieżki — bez znaku zapytania
?, kratki#i spacji (zrywają adresy w feedzie i mapie witryny).
Przykłady poprawnych szablonów:
| Zasób | Szablon | Efekt |
|---|---|---|
| Kategoria produktu | /{slug} | twoj-sklep.pl/zimowe-kurtki |
| Produkt | /p/{slug} | twoj-sklep.pl/p/czapka-beanie |
| Kategoria bloga | /{locale}/blog/category/{slug} | twoj-sklep.pl/en/blog/category/poradniki (język inny niż domyślny) |
Szablony mówią platformie, jak wygląda routing Twojego storefrontu — nie konfigurują samego storefrontu. To Ty odpowiadasz za to, by zadeklarowana ścieżka faktycznie renderowała treść w Twojej aplikacji. Deklaracja niezgodna z rzeczywistym routingiem sprawi, że podgląd i feed wskażą adresy, których storefront nie obsługuje.
Mapa witryny (sitemap)
Platforma generuje mapę witryny (sitemap.xml) Twojego sklepu jako instalowaną aplikację — spis publicznych adresów (produkty, kategorie, marki, kolekcje, strony treści oraz blog) w formacie zrozumiałym dla wyszukiwarek. Adresy w mapie są składane z tej samej konwencji, którą opisano wyżej (domyślnej albo zadeklarowanej przez Ciebie w szablonach sklepu), więc mapa zawsze wskazuje adresy zgodne z routingiem Twojego storefrontu — a gdy zmienisz konwencję, podąża za nią automatycznie. Mapa obejmuje wyłącznie zasoby, które Twój storefront routuje: sekcje zadeklarowane jako nieroutowane (oraz domyślnie wyłączone strony treści i osie bloga — patrz wyżej) są pomijane, żeby do indeksu nie trafiały adresy prowadzące do 404. Platforma generuje mapę i odświeża ją raz na dobę.
Jak podłączyć mapę do storefrontu
Aplikacja udostępnia stały adres mapy witryny — na domenie Twojego sklepu (https://twoja-domena/storefront/sitemap.xml). Aby wyszukiwarki ją znalazły:
- Skopiuj adres mapy z panelu aplikacji „Mapa witryny".
- Wskaż go na jeden z dwóch sposobów:
- dodaj w pliku
robots.txtswojego storefrontu linięSitemap:z tym adresem, albo - prześlij adres bezpośrednio w Google Search Console.
- dodaj w pliku
Przykładowy robots.txt storefrontu z odwołaniem do mapy:
User-agent: *
Allow: /
# Adres skopiowany z panelu aplikacji „Mapa witryny" — na domenie Twojego sklepu
Sitemap: https://twoja-domena.pl/storefront/sitemap.xml
Adres pozostaje stały, więc wystarczy wskazać go raz — kolejne odświeżenia mapy są widoczne pod tym samym linkiem. Indeks mapy odwołuje się do plików części pod tą samą domeną (/storefront/sitemaps/...), więc nie musisz podawać ich osobno.
Hostujesz storefront samodzielnie? Przekaż dwie ścieżki mapy
Mapa jest serwowana na domenie Twojego sklepu, ale jej treść przygotowuje API platformy. Jeśli wdrażasz storefront przez platformę, działa to od razu — nie musisz nic konfigurować. Jeśli hostujesz storefront na własnej infrastrukturze, przekaż dwie ścieżki mapy do API platformy, dokładając nagłówek x-shop-slug z uchwytem (slugiem) Twojego sklepu.
W Next.js (App Router) wystarczą dwa route handlery, które proxują żądanie do API platformy:
// app/storefront/sitemap.xml/route.ts — indeks mapy
export async function GET() {
const res = await fetch(`${process.env.DOSWIFTLY_API_URL}/storefront/sitemap.xml`, {
headers: { 'x-shop-slug': process.env.DOSWIFTLY_SHOP_SLUG! },
});
return new Response(res.body, {
status: res.status,
headers: {
'content-type': res.headers.get('content-type') ?? 'application/xml',
// Propaguj politykę cache platformy (sukces jest cache'owalny,
// odpowiedzi błędne nigdy nie zatruwają cache po Twojej stronie).
'cache-control': res.headers.get('cache-control') ?? 'no-store',
},
});
}
// app/storefront/sitemaps/[file]/route.ts — pliki części mapy
export async function GET(
_req: Request,
{ params }: { params: Promise<{ file: string }> },
) {
const { file } = await params;
const res = await fetch(`${process.env.DOSWIFTLY_API_URL}/storefront/sitemaps/${file}`, {
headers: { 'x-shop-slug': process.env.DOSWIFTLY_SHOP_SLUG! },
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': 'application/xml' },
});
}
Zmienne DOSWIFTLY_API_URL i DOSWIFTLY_SHOP_SLUG należą do standardowego kontraktu środowiskowego storefrontów — przy wdrożeniu przez platformę są ustawiane automatycznie; przy w pełni samodzielnym hostingu ustaw je sam.
Masz własną, dodatkową mapę?
Jeśli Twój storefront ma osobną mapę witryny poza platformą (np. dla landingów albo treści, których platforma nie zna), nie musisz zgłaszać jej oddzielnie w Google Search Console. W ustawieniach aplikacji „Mapa witryny" wklej jej pełny adres w polu dodatkowych map — zostanie doklejona do wspólnego indeksu mapy przy najbliższej generacji. Możesz podać kilka adresów oddzielonych przecinkami; obsługiwane są wyłącznie adresy https.
Dlaczego backend nie zwraca gotowego adresu
Storefront API zwraca handle (slug) każdej treści, nigdy gotowego URL-a. Powód jest architektoniczny: platforma nie zna — i nie powinna narzucać — routingu aplikacji, którą budujesz. Router, prefiksy językowe, zagnieżdżenie ścieżek i domena to Twoje decyzje.
To standardowy podział w architekturze headless: warstwa danych wydaje stabilny identyfikator (handle), a warstwa prezentacji składa z niego ścieżkę. Dzięki temu ta sama treść może żyć pod różnymi adresami w różnych storefrontach, a zmiana routingu nie wymaga żadnej zmiany po stronie API.
Deklaracja własnej konwencji (sekcja wyżej) nie łamie tej zasady. Platformowe artefakty — podgląd w panelu, feed produktowy Google, mapa witryny — nie narzucają adresu; przewidują go na podstawie konwencji, którą sam zadeklarowałeś (albo domyślnej, jeśli nie zadeklarowałeś nic). Routing pozostaje po Twojej stronie; platforma jedynie odtwarza go tam, gdzie sama musi wydrukować pełny link.
# API wydaje handle — nie URL.
query ProductByHandle($handle: String!) {
product(handle: $handle) {
id
title
handle # np. "bluza-z-kapturem" — ścieżkę /products/bluza-z-kapturem składasz sam
}
}
Segment-strażnik: kategoria i tag bloga
Zauważ, że kategoria i tag bloga mają w domyślnej konwencji dodatkowy segment pośredni (category/, tag/), którego artykuł nie ma. To nie jest przypadek — to strażnik przestrzeni nazw.
Trasa artykułu /blog/{slug} zajmuje całą przestrzeń nazw bezpośrednio pod /blog/. Gdyby kategoria mieszkała pod /blog/{slug}, adres /blog/poradniki byłby jednocześnie:
- artykułem o slugu
poradniki, oraz - kategorią o nazwie
poradniki,
a to, który z nich się wyświetli, rozstrzygałby przypadek (kolejność sprawdzania w routerze). Segment category/ (i analogicznie tag/) rozdziela te przestrzenie nazw, więc /blog/poradniki (artykuł) i /blog/category/poradniki (kategoria) nigdy nie kolidują.
Jeśli dokładasz własne podstrony pod /blog/ (np. archiwum, autorzy), nadaj im własny segment-strażnik, tak jak zrobiono to dla kategorii i tagów. Wrzucenie ich bezpośrednio pod /blog/{cokolwiek} wejdzie w konflikt z trasą artykułu.
Blog — kategorie i tagi budujesz samodzielnie
Trasy kategorii bloga (/blog/category/{slug}) i tagu (/blog/tag/{slug}) to strony, które tworzysz w swoim storefroncie. Nie są to gotowe komponenty — to Ty definiujesz ich router i widok.
Backend jest na to gotowy:
- Typ
BlogCategoryudostępnia:handle,name,description,image,postCountorazseo. - Typ
BlogTagudostępnia:handle,name,postCount. - Zapytanie
blogPosts(categoryHandle: String, tagHandle: String)filtruje wpisy po uchwycie kategorii lub tagu.
Zapytania GraphQL dla strony kategorii bloga
Nie ma osobnego zapytania „pobierz jedną kategorię po handle" — metadane kategorii (nazwę, opis, SEO) pobierasz z listy blogCategories i wybierasz pozycję o pasującym handle. Wpisy tej kategorii pobierasz przez blogPosts z argumentem categoryHandle:
# Metadane wszystkich kategorii bloga — do nawigacji i nagłówka strony kategorii.
query BlogCategories {
blogCategories {
id
name
handle
description
image {
url
altText
}
postCount
seo {
title
description
}
}
}
# Wpisy przefiltrowane po uchwycie kategorii.
# Ten sam wzorzec z argumentem `tagHandle` obsłuży stronę tagu.
query BlogPostsByCategory($categoryHandle: String, $first: Int = 20, $after: String) {
blogPosts(categoryHandle: $categoryHandle, first: $first, after: $after) {
edges {
node {
id
title
handle
excerpt
featuredImage {
url
altText
}
publishedAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
blogPosts zwraca Relay Connection, nie tablicęWynik blogPosts to połączenie ze strukturą edges { node } — rozpakuj go przez edges.map((edge) => edge.node), zanim wyrenderujesz listę. Traktowanie połączenia jak tablicy da pustą listę.
Przykładowa trasa
Trasa dla strony kategorii bloga w Next.js (App Router). W bieżącej wersji Next.js params jest obiektem Promise — pamiętaj o await params:
// app/[locale]/blog/category/[slug]/page.tsx
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
// Server helpers wygenerowane lokalnie z operacji GraphQL (codegen).
import { fetchBlogCategories, fetchBlogPostsByCategory } from '@/lib/graphql/server';
interface BlogCategoryPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: BlogCategoryPageProps): Promise<Metadata> {
const { slug } = await params;
const { blogCategories } = await fetchBlogCategories();
const category = blogCategories.find((c) => c.handle === slug);
if (!category) return { title: 'Nie znaleziono kategorii' };
return {
// `seo` jest już gotowe do renderu: pusty meta tytuł zastępuje nazwa, pusty
// meta opis — opis kategorii jako czysty tekst. Nie składaj zastępstwa sam,
// a zwłaszcza nie wstawiaj tu `category.description`: to pole zwraca HTML.
title: category.seo?.title,
description: category.seo?.description,
};
}
export default async function BlogCategoryPage({ params }: BlogCategoryPageProps) {
const { slug } = await params;
const { blogCategories } = await fetchBlogCategories();
const category = blogCategories.find((c) => c.handle === slug);
if (!category) notFound();
const { blogPosts } = await fetchBlogPostsByCategory({ categoryHandle: slug });
// Relay Connection → płaska lista wpisów.
const posts = blogPosts.edges.map((edge) => edge.node);
return (
<section className="container py-8">
<h1 className="text-3xl font-bold">{category.name}</h1>
{category.description && (
<p className="text-muted-foreground mt-2">{category.description}</p>
)}
<ul className="mt-6 grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<li key={post.id}>
{/* Ścieżkę artykułu składasz z segmentu /blog/ + handle wpisu. */}
<a href={`/blog/${post.handle}`}>{post.title}</a>
</li>
))}
</ul>
</section>
);
}
Stronę tagu (/blog/tag/[slug]) budujesz identycznie — użyj argumentu tagHandle zamiast categoryHandle i listy blogTags zamiast blogCategories.
Wielojęzyczność — dwie niezależne osie
Jeśli Twój sklep jest wielojęzyczny, na adres URL składają się dwie niezależne osie. Mylenie ich to najczęstsze źródło błędów.
| Oś | Co to jest | Skąd pochodzi | Kiedy jest znana |
|---|---|---|---|
| Segment ścieżki | kategoria vs category, produkty vs products | statyczna mapa w routerze storefrontu (pathnames) lub deklaracja w szablonach sklepu (urlTemplates) | w czasie budowania / konfiguracji |
| Slug treści | poradniki vs guides | dane z API (handle) | w czasie działania (runtime) |
Segment to fragment statyczny. Masz dwie drogi, by go zlokalizować:
- W routerze storefrontu (
pathnamesw next-intl) — pełna swoboda tłumaczenia segmentu per język, ale zna go tylko Twój storefront. Podgląd w panelu i feed użyją wtedy domyślnej konwencji (lub tego, co osobno zadeklarujesz w szablonach). - W szablonach sklepu (
urlTemplates) — deklarujesz kształt ścieżki raz, a platforma go zna, więc podgląd, feed i mapa witryny mówią tym samym adresem. Szablon ustawia jeden kształt (z opcjonalnym prefiksem języka), nie mapę różnych segmentów per język.
Slug to fragment dynamiczny, który przychodzi z API razem z treścią — nie tłumaczy go żadna z tych warstw.
Tłumaczenie segmentu w routerze (pathnames)
next-intl pozwala zmapować jedną ścieżkę wewnętrzną na różne ścieżki zewnętrzne per język. Ścieżkę wewnętrzną trzymaj w angielskich segmentach (spójnie z domyślną konwencją), a router wyrenderuje właściwy wariant językowy:
// Mapa pathnames (next-intl) — tłumaczy WYŁĄCZNIE statyczny segment ścieżki.
// Klucz = ścieżka wewnętrzna (angielska, jedna), wartość = ścieżka zewnętrzna per język.
const pathnames = {
'/blog/category/[slug]': {
pl: '/blog/kategoria/[slug]',
en: '/blog/category/[slug]',
},
'/products/[slug]': {
// Segment identyczny w obu językach — nie każdy trzeba tłumaczyć.
pl: '/products/[slug]',
en: '/products/[slug]',
},
};
Dynamiczny fragment ([slug]) pathnames nie tłumaczy — jest wypełniany danymi z API. To zgodne z dokumentacją next-intl: pathnames lokalizuje tylko statyczny fragment ścieżki, a segment dynamiczny dostarcza CMS / API.
Referencyjna konfiguracja tego storefrontu celowo nie definiuje statycznej listy języków w routerze — lista supportedLanguages jest pobierana z backendu (można dodać/usunąć język bez zmiany kodu). Z tego powodu klasyczny przepis next-intl defineRouting({ locales: [...], pathnames: {...} }) ze statyczną listą lokali nie stosuje się wprost. Jeśli chcesz lokalizować segmenty, wypisz w mapie pathnames tylko te konkretne języki, dla których faktycznie tłumaczysz ścieżki; dla pozostałych segment pozostaje taki sam. Najprostsza droga to trzymać segmenty identyczne we wszystkich językach (jak w tabeli wyżej) i lokalizować wyłącznie treść.
Do ustawienia domyślnego języka w adresie storefront używa trybu prefiksu as-needed: język domyślny nie ma prefiksu w ścieżce (/blog/...), a pozostałe języki mają (/en/blog/...). To ten sam tryb, który stosuje znacznik {locale} w szablonach adresów.
Slug treści jest dziś wspólny dla wszystkich języków
Obecnie slug treści jest jeden, wspólny dla wszystkich języków. Tłumaczone są nazwa i opis kategorii/tagu, ale nie jej slug — w każdym języku dynamiczny fragment ścieżki pozostaje taki sam (np. poradniki również w wersji angielskiej). Lokalizacja slugów (osobny handle per język) jest planowana, ale jeszcze nie jest dostępna. Nie zakładaj w kodzie, że możesz dziś pobrać z API odrębny slug dla konkretnego języka.
Zmiana slugu zrywa linki
Slug jest częścią adresu URL, więc jego zmiana zmienia adres treści. Platforma nie tworzy dziś automatycznie przekierowań przy zmianie slugu — stary adres po prostu przestaje działać (404), a zewnętrzne linki, zakładki i pozycje w wynikach wyszukiwania przestają trafiać do celu.
Miej to na uwadze przy projektowaniu storefrontu:
- Traktuj zmianę slugu opublikowanej treści jako operację zrywającą linki, nie jako kosmetykę.
- Jeśli potrzebujesz stabilnych przekierowań po zmianie adresów, musisz obsłużyć je we własnej warstwie (np. mapa przekierowań w storefroncie).
Powiązane
- Internacjonalizacja (i18n) — przełączanie języka, cookie języka, synchronizacja z SDK.
- Strategia renderowania — jak wybór SSR/ISR wpływa na obsługę tras.
- Więcej funkcji — kolekcje, marki, karty podarunkowe i inne treści z własnymi adresami.