Exploring Next.js Project Structure and Sanity CMS Through a Simple Hotel Information Management System
Imagine a small hotel that needs a website to showcase its rooms infomation and update its latest offers, but the hotel team has no background in web development. How are they going to make changes on their official website.
This is where a headless CMS like Sanity comes in. Compared to traditional CMS tools, Sanity doesn’t provide front-end templates out of the box. Instead, it essentially acts as a content management system that provides an interface for both developers and business users to manage content.
More specifically, Sanity focuses on organizing content, the content team prepares the materials, and the developers set up the redering process.
A simple Next.js project for a hotel website might look like this:
hotel-directory/
├── studio/ # CMS backend
│ ├── sanity.config.ts
│ ├── sanity.cli.ts
│ └── schemaTypes/
│ ├── hotelType.ts
│ └── index.ts
│
└── frontend/ # Website
├── src/app/
│ ├── page.tsx
│ └── layout.tsx
│
├── src/sanity/
│ ├── env.ts
│ ├── lib/
│ │ ├── client.ts
│ │ ├── queries.ts
│ │ └── getPost.ts
│ └── sanity.types.ts # Generated
│
├── next.config.ts
├── tsconfig.json
├── .env.local
└── package.json
The studio/ directory contains the Sanity Studio configuration and content schemas.
The hotelType.ts file defines the structure of hotel documents in Sanity. For example, a hotel document might contain a hotel name, description, room information, images, and special offers. These definitions determine which fields content editors can fill in.
Eample of hotelType.ts:
// studio/schemaTypes/hotelType.ts
import {defineType, defineField} from 'sanity';
export const hotelType = defineType({
name: 'hotel',
title: 'Hotel',
type: 'document',
fields: [
defineField({
name: 'name',
title: 'Hotel Name',
type: 'string',
validation: rule => rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
}),
defineField({
name: 'rating',
title: 'Rating',
type: 'number',
validation: rule => rule.min(0).max(5),
}),
defineField({
name: 'city',
title: 'City',
type: 'string',
}),
],
});
The above code shows a simplified version of the hotel schema, We also need to register this schema with Sanity Studio.
// studio/schemaTypes/index.ts
import {hotelType} from './hotelType';
export const schemaTypes = [hotelType];
// studio/sanity.config.ts
import {defineConfig} from 'sanity';
import {schemaTypes} from './schemaTypes';
export default defineConfig({
name: 'default',
title: 'Hotel Directory',
projectId: 'your-project-id',
dataset: 'production',
plugins: [],
schema: {
types: schemaTypes,
},
});
The frontend/ directory contains the Next.js application. It is responsible for retrieving content from Sanity and displaying it to website visitors. The page.tsx file defines the UI for a particular route; The layout.tsx file defines the shared layout for the routes inside its directory. It can contain common elements such as fonts, navigation, and footer components.
The src/sanity/ directory contains the code that connects the Next.js frontend to Sanity and helps the application retrieve and use content. And we also have two important files under this directory, client.ts and queries.ts. The client.ts file is used to connect Sanity and excutes requests, and queries.ts is to define the GROQ query for the latest five posts.
//client.ts
import { createClient } from '@sanity/client'
export const client = createClient({
projectId: 'your-project-id', //which sanity project to connect to
dataset: 'production', //which dataset to access
apiVersion: '2026-03-01', //which version of the Sanity API to use
useCdn: true, //whether to use Sanity's content delivery network for queries.
})
//queries.ts (Sanity uses a query language called GROQ to select and filter content)
import { defineQuery } from 'groq'
export const latestPostsQuery = defineQuery(`
*[_type == "post"]
| order(publishedAt desc)[0...5] {
_id,
title,
slug,
publishedAt
}
`)
//GROQ expression
* Start with all documents.
[_type == "post"] Keep only documents of type post.
order(publishedAt desc) Sort newest first.
[0...5] Return the first five results.
{ title, slug } Select the specified fields.
And we can have a getPost.ts to combines client.ts and queries.ts into a resuable data-fetching function.
//getPost.ts
import { client } from './client'
import { latestPostsQuery } from './queries'
export async function getLatestPosts() {
return await client.fetch(latestPostsQuery)
}
And actually the above three can be put together, it works too. However, the reason to separate the files is that, as the application grows, we might have 20 different queries but still want one shared client. And we can also use the same query definitions in different functions without duplicating the query strings. And actually queries.ts is a naming convention, not a special Sanity reqirement. We can give it another name or put query definitions in another module. But the important thing is how the code is organized and imported.