Unit 1 · Module 5
Route Groups, Dynamic Routes & Sitemaps
Folder conventions that shape your URLs
A quick note before the brackets
This page covers two folder-naming tricks: wrapping folders in (parentheses) and
wrapping them in [square brackets]. Both are used by Next.js. But the idea of
giving folder names special meaning isn't unique to Next.js — almost every modern framework
does some version of this. We'll note where the conventions overlap so you can recognize them
later in other projects.
Parentheses are invisible folders
Look at Opsette's app/ folder:
app/
app/
├── (auth)/
├── (dashboard)/
├── (marketing)/
├── (pos)/
├── (storefront)/
├── api/
├── auth/
├── b/
├── booking/
├── embed/
├── f/
├── hub/
├── portal/
├── products/
├── providers/
├── sign/
├── GoogleAnalytics.tsx
├── globals.css
├── layout.tsx
├── page.tsx
└── sitemap.ts
Notice the folders wrapped in parentheses — (auth), (dashboard),
(marketing), (pos), (storefront). Those are
route groups.
Normally, a folder called dashboard in Next.js becomes /dashboard.
The folder name is the route. But if you wrap that folder in parentheses —
(dashboard) — the framework sees those and says "this folder is just for
organization. Don't put its name in the URL."
So everything inside (dashboard) is still routed. The pages still work. The URL
just never has the word "dashboard" in it. The user sees /bookings, not
/(dashboard)/bookings.
Why route groups exist
Route groups solve a specific problem: you want to group pages together without that grouping becoming visible in the URL.
You might want all authenticated pages under (dashboard) so they share a layout
with a sidebar. You might want all public marketing pages under (marketing) so
they share a different layout with a nav bar. You might want a checkout flow grouped under
(pos) so all its pages use the point-of-sale layout.
Route groups let you share layouts across sections of the app without mangling your URLs.
Square brackets are variables
Now look at Opsette's app/b/[org]/ folder:
app/b/[org]/
app/b/[org]/
├── [service]/
└── page.tsx
Those square brackets around org and service mean the folder name is
a variable, not a fixed word. These are called dynamic routes.
So app/b/[org]/[service]/page.tsx doesn't mean there's a literal folder called
org on your server. It means: whatever the org slug is, put it here. Whatever
the service is, put it there.
When someone visits opsette.io/b/dr-smith/haircut:
[org]becomesdr-smith[service]becomeshaircut- The page at
page.tsxruns with those values available to it
That's how one page.tsx file can render thousands of different URLs — one for
every provider, every service, every booking. The structure is fixed. The values inside are
dynamic.
Why b and f instead of booking and forms
In Opsette, the public booking interface lives at app/b/[org]/[service] instead of
app/booking/[org]/[service]. Same with app/f/ for public forms.
That's not just a shortcut — it's actively keeping the URL short and shareable.
A booking link that says opsette.io/b/dr-smith/consultation is a lot friendlier
than opsette.io/booking/dr-smith/consultation. Same page, cleaner share. The
single-letter folder is a deliberate architectural choice to separate the short public-facing
route from the longer internal /booking/* admin routes.
sitemap.ts — telling Google what exists
At the bottom of Opsette's app/ folder sits a file called sitemap.ts.
It's not a page. It's not a layout. It's a special file Next.js looks for — if it's there, the
framework automatically serves its output at /sitemap.xml, which is the standard
location search engines look for.
A sitemap is a list of every page that exists on your site. Google's crawler visits your domain, finds your sitemap, and uses it as a map of what to index. Without one, Google has to discover your pages by following links — slower, and pages with no links into them can get missed entirely.
This file is a great example because it pulls together almost everything you've learned so far — imports, functions, types, async/await, objects, and the file structure that lets it all sit in one place. Let's break it down piece by piece.
1. The imports — what this file needs to do its job
app/sitemap.ts — imports
import type { MetadataRoute } from 'next'
import supabaseServer from '@/integrations/supabase/server'
import client as sanityClient from '../sanity/lib/client' Three imports, three different jobs:
-
MetadataRoutefromnext— this is a type, not a function. Noticeimport type. It tells TypeScript the shape this file's return value has to match. From Module 4 — this is an interface being pulled in as a blueprint for what a sitemap is allowed to look like. -
supabaseServer— a service (remember Module 5's definition — code that talks to the outside world). It's the connection to the Supabase database, set up once and imported wherever it's needed. -
sanityClient— another service, this time for Sanity, the CMS Opsette uses for blog posts.import client as sanityClientjust means "bring in the thing calledclient, but refer to it assanityClientinside this file" — a rename so it's clearer what it does.
2. The function signature — a labeled entry point
app/sitemap.ts — function declaration
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// ...body goes here
} Read that line one word at a time:
export default— this is the main thing this file offers. Next.js looks for a default export to know what to run.async— this function does work that takes time (database queries). It returns a Promise.function sitemap()— the name. Next.js specifically looks for a sitemap file and runs whatever it exports.: Promise<MetadataRoute.Sitemap>— this is TypeScript saying "whatever this function returns, it has to eventually match the shape of aMetadataRoute.Sitemap." That's the blueprint from the first import being enforced.
3. Fetching from two sources in parallel
app/sitemap.ts — data fetching
const [{ data: providers }, blogPosts] = await Promise.all([
supabaseServer
.from('public_company_profiles')
.select('organization_slug')
.not('organization_slug', 'is', null)
.eq('is_deleted', false)
.order('company_name'),
sanityClient.fetch(/* ...blog posts query... */)
]) Two data sources, fetched at the same time to save time. Let's break it down:
-
Promise.all([...])— runs both queries in parallel instead of waiting for one to finish before starting the other. If each takes 200ms, doing them one after the other is 400ms. Doing them together is 200ms. - The Supabase chain —
.from(...)picks the table,.select(...)picks the columns,.not(...)and.eq(...)are filters (skip nulls, skip deleted rows),.order(...)sorts the results. Each method returns something the next method can keep building on — that's called method chaining, and it reads like an English sentence when you get used to it. -
sanityClient.fetch(...)— Sanity has its own query language. Same idea as the Supabase call, different syntax. The service abstracts away the details. - The destructuring on the left —
[{ data: providers }, blogPosts]pulls the two results out of the array thatPromise.allreturns. Supabase returns an object with adataproperty, so{ data: providers }reaches inside and grabs just the array of providers. Sanity returns the array directly, soblogPostsgets it as-is.
4. Mapping each row into a URL object
app/sitemap.ts — building URLs
const providerUrls = (providers || []).map((p) => ({
url: `https://opsette.io/providers/${p.organization_slug}`,
changeFrequency: 'weekly',
priority: 0.8,
}))
const blogUrls = (blogPosts || []).map((post) => ({
url: `https://opsette.io/blog/${post.slug}`,
lastModified: post.publishedAt,
changeFrequency: 'monthly',
priority: 0.7,
})) Each block does the same job for a different data source. Reading providerUrls:
-
(providers || [])— a safety net. Ifproviderscame back asnullorundefined, use an empty array instead so the next step doesn't crash. -
.map((p) => (...))— loop over every provider and transform it into something new. For each providerp, return an object. -
url: `https://opsette.io/providers/$${p.organization_slug}`— a template literal (the backticks). Builds a URL string and drops the provider's slug into it. A provider with slugdr-smithbecomeshttps://opsette.io/providers/dr-smith. -
changeFrequencyandpriority— hints to Google. "This page updates weekly, treat it as pretty important." Blog posts update less often (monthly) and are a touch lower priority.
5. Returning the combined list
app/sitemap.ts — the return
return [...providerUrls, ...blogUrls]
}
The ... is the spread operator. It takes the contents of one
array and drops them into another. So this line says: "return a new array containing all the
provider URLs, followed by all the blog URLs." The final shape matches the
MetadataRoute.Sitemap blueprint TypeScript was enforcing at the top.
What this file actually does, end to end
That's not a static file someone typed out. It's generated fresh from the database every time it's requested. When a new provider signs up, they automatically appear in the sitemap the next time Google asks for it. No manual update. No cron job. The file is the map.
Three conventions, one system
Inside the app/ folder, three folder-naming patterns shape your URLs:
- Regular folder names — become part of the URL exactly as written
(parentheses)— invisible grouping, never appear in the URL[square brackets]— variables, filled in at runtime from the URL
Once you can read those three, you can look at any Next.js project's app/ folder
and know what its URL map looks like without running the app. And when you jump to a different
framework in the next unit, you'll recognize the same ideas even if the syntax shifts
to dollar signs or underscores.