A Nuxt project tour
Create a Nuxt 4 project, learn what each folder is for, let auto-imports find your components and composables, and keep secrets on the server with runtimeConfig.
What you'll learn
When you finish, you can do these.- 1Create a Nuxt project and start its dev server
- 2Choose the right folder for Vue, server and shared code
- 3Use components and composables with no import lines
- 4Keep an API key out of the browser with runtimeConfig
The Corner Shop, the small online shop this track builds, needs more than a few Vue components: a page for each URL, products ready before the page shows, a server that can hold a secret key, and a way to put it all online. Nuxt, a framework built on Vue, sets all of this up. A file's folder makes it a page or server code, and each page is rendered on a server before it reaches the browser.
The Code tab holds the shop's new Nuxt project, with a few files added. By the end you can create it yourself and say where each file belongs. You need one idea from the lesson Components and props, in the Beginner track: a component is one .vue file for one piece of the page, and props are the values you hand it, such as the words on a badge.
Create a project and find your way around
Three commands in a terminal create the shop's project, move into its folder and start it:
npm create nuxt@latest my-shop
cd my-shop
npm run dev
The first runs Nuxt's command-line tool, which asks a few questions; pick the minimal template. npm run dev starts the dev server, which serves the app at http://localhost:3000 and updates the page as you save.
Each folder has one job, and Nuxt finds your files by where they sit.
- npm create nuxt@latest
- app/pages, components, composables, layouts
- server/api, utils
- shared/utils, types
- nuxt.config.ts
app/holds the Vue side:pages/with one file per URL,components/,composables/for reusable functions whose names start withuse, andutils/for helpers.server/holds code that never runs in the browser:server/api/products.get.tsanswersGET /api/products, the page's request for the products.shared/holds code both sides use, such as a price formatter or aProducttype.nuxt.config.tsholds the settings.
app/app.vue is the root component, the one every page renders inside, like App.vue in a plain Vue app. In a new project it shows <NuxtWelcome />, Nuxt's welcome screen. The shop replaces it with a header every page shares:
<template>
<div>
<header>
<NuxtLink to="/">{{ config.public.shopName }}</NuxtLink>
<span>Cart: {{ count }}</span>
</header>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
Every page now starts with a Corner Shop link and the text Cart: 0. config and count come from the file's script block in the Code tab; the next two sections explain both.
<NuxtLink> links between pages, and <NuxtPage /> renders the page file that matches the URL. <NuxtLayout> wraps it in a layout, a frame some pages share (the Layouts and page meta lesson); with none, it changes nothing.
Nuxt DevTools, a panel that lists your pages, components and auto-imported names, is on in every new project: press Shift + Alt + D (Shift + Option + D on a Mac).
Auto-imports: no import lines
Nuxt imports the names you use, so you rarely write an import line. These auto-imports cover Vue's ref and computed, Nuxt's functions such as useRuntimeConfig, and your own files in app/components/, app/composables/ and app/utils/.
The shop's cart is a composable, app/composables/useCart.ts. Its useState(), a Nuxt function covered later in this track, gives every component the same cart:
export function useCart() {
const items = useState<string[]>('cart', () => [])
const count = computed(() => items.value.length)
function add(name: string) {
items.value.push(name)
}
return { items, count, add }
}
add() puts a name in the cart, and count, the number in the header, goes up.
Each product card, app/components/shop/ProductCard.vue, takes a product's name and price as props and calls useCart() with no import line:
<script setup lang="ts">
defineProps<{ name: string, price: number }>()
const { add } = useCart()
</script>
<template>
<article class="product">
<h2>{{ name }}</h2>
<p>{{ formatPrice(price) }}</p>
<button @click="add(name)">Add to cart</button>
</article>
</template>
The card shows a name, a price such as £1.89 for oat milk, and an Add to cart button. formatPrice(), which turns pence into pounds, comes from shared/utils/, also without an import.
Components are found in subfolders, with the folder in the name: the home page, app/pages/index.vue, writes the card as <ShopProductCard>.
Composables and utils work differently. Nuxt scans only the top level of app/composables/ and app/utils/, so a file one folder down is missed until you list its folder under imports.dirs in nuxt.config.ts.
shared/utils/ and shared/types/ are auto-imported on both sides, and server/utils/ on the server only.
Settings and secrets with runtimeConfig
runtimeConfig in nuxt.config.ts holds values that differ between your laptop and the live site. The shop has two: its name, and an API key, a password its code sends to the inventory service to ask what is in stock. Each gets a default, which an environment variable, a value set outside your code, can override when the app starts:
runtimeConfig: {
// Server only. Set it with NUXT_INVENTORY_API_KEY.
inventoryApiKey: '',
public: {
// Server and browser. Set it with NUXT_PUBLIC_SHOP_NAME.
shopName: 'Corner Shop',
},
},
A setting at the top level is private: the server can read it and the browser never receives it. A setting under public reaches both.
The variable that overrides a setting is its path in capitals with a NUXT_ prefix, so public.shopName becomes NUXT_PUBLIC_SHOP_NAME.
Components read the settings with useRuntimeConfig(): it gives app/app.vue its config. A server route, a file in server/api/ that answers one URL, also passes in event, the request that defineEventHandler() hands to its code:
export default defineEventHandler(async (event) => {
const { inventoryApiKey } = useRuntimeConfig(event)
if (!inventoryApiKey) {
return sampleProducts // no key in development: serve sample data
}
return $fetch<Product[]>('https://inventory.example.com/v1/products', {
headers: { Authorization: `Bearer ${inventoryApiKey}` },
})
})
With no key, the route sends the sample products from server/utils/. With one, $fetch, Nuxt's request function, asks the inventory service. Either way the key stays on the server.
In development Nuxt reads these variables from a .env file. The built server, the version you put online, does not: set them on your host instead. Keep .env out of git, and commit an .env.example with the names only, like the one in the Code tab.
What goes wrong
The first mistake is a composable moved into a subfolder, here app/composables/cart/.
export function useCart() {
// the same code as before, one folder down
}
The page fails with a 500 server error: useCart is not defined. Move the file back, or tell Nuxt to scan subfolders and restart the dev server:
export default defineNuxtConfig({
imports: { dirs: ['~/composables/**'] },
})
In Nuxt 4, ~ points at app/, so this covers every folder under app/composables/.
The second mistake is a secret under public. It works perfectly, and that is the problem.
runtimeConfig: {
public: {
inventoryApiKey: '', // NUXT_PUBLIC_INVENTORY_API_KEY
},
},
Products load, and the key travels with every page: the page's HTML holds it inside window.__NUXT__.config, where Nuxt writes the public config. Anyone who opens View Source can copy it. Move the key to the top level:
runtimeConfig: {
inventoryApiKey: '', // NUXT_INVENTORY_API_KEY, server only
public: {
shopName: 'Corner Shop',
},
},
The server route still reads it, and the page source no longer holds it.
What to remember
npm create nuxt@latestcreates a project, andnpm run devserves it on port 3000.app/is the Vue side,server/runs only on the server, andshared/holds code both sides use.app/app.vueis the root, and<NuxtPage />shows the current page.- Your components, composables and utils are auto-imported, like Vue's and Nuxt's functions. Components are found in subfolders, composables and utils only at the top level unless you add
imports.dirs. - Private
runtimeConfigsettings stay on the server, andpublicones are written into every page. On the live site, set them withNUXT_environment variables: the built server ignores.env.
In your project
Do these in a real project. Each one says how to confirm it worked.- Create a project with
npm create nuxt@latest my-shopand start it. Replace<NuxtWelcome />inapp/app.vuewith<NuxtPage />, and addapp/pages/index.vuewith a single<main>element. Press Shift + Alt + D: the DevTools Pages tab lists/. - Add
app/composables/useCart.ts, call it from the page, then move it intoapp/composables/cart/: the page fails withuseCart is not defined. Addimports: { dirs: ['~/composables/**'] }tonuxt.config.tsand restart: the page works, and the DevTools Imports tab listsuseCart. - Put a made-up key such as
test-key-123underruntimeConfig.publicand search View Source for it: it sits insidewindow.__NUXT__.config. Move it to the top level and reload: it is gone from the source.