Gridsome

Vue.js + GraphQL based SSG. Developed as Vue.js version of Gatsby with over 8k GitHub stars.

言語:JavaScript/TypeScript
フレームワーク:Vue.js
ビルド速度:Medium
GitHub Stars:8k
初回リリース:2018
人気ランキング:第9位

トレンド・動向

Supported by developers seeking Gatsby-like development experience with Vue.js. Features data management with GraphQL.

# Static Site Generator Gridsome ## Overview Gridsome is "a Vue.js + GraphQL based SSG" developed as the Vue.js version of Gatsby, positioning itself as a static site generator for developers who want a Gatsby-like development experience with Vue.js. With over 8k GitHub stars, it is supported by developers seeking this specific combination. Since its development began in 2018, it features unified data management in the Vue.js ecosystem and a powerful data layer through GraphQL, realizing fast and secure website construction through the Jamstack approach. Its unique architecture serves static HTML that later hydrates into a fully functional Vue.js SPA, achieving both excellent performance and development efficiency. ## Details Gridsome 1.x has been designed since its 2018 release to enable Vue.js developers to achieve the same development experience as Gatsby within the Vue.js ecosystem. By adopting the PRPL pattern, code splitting, asset optimization, progressive images, and link prefetching are built-in as standard features. The GraphQL data layer enables unified management of diverse data sources such as headless CMS, APIs, and Markdown files. It generates one .html file and one .json file per page, achieving fast page transitions using only .json files after the initial page load. With a lightweight bundle size of 57KB min gzip (including Vue.js, vue-router, vue-meta, and image lazy loading), it achieves almost perfect page speed scores. ### Key Features - **Vue.js-based Frontend**: Simple and approachable development experience with Vue.js - **GraphQL Data Layer**: Unified query interface for data management - **Automatic Code Splitting**: Optimized JS bundle generation per page - **File-based Routing**: Automatic route generation from .vue files in src/pages - **PWA Support**: Offline-first architecture and progressive web app functionality - **Prefetch Optimization**: Automatic prefetching of next page data ## Pros and Cons ### Pros - Approachable development experience and component-based design in Vue.js ecosystem - Unified and powerful data management and integration through GraphQL - Lightweight and fast site generation through automatic performance optimization - Flexible data source integration for headless CMS, APIs, and Markdown - Gatsby-like Jamstack development experience realized with Vue.js - CDN deployment and low-cost operation support in serverless environments ### Cons - Smaller plugin ecosystem compared to Gatsby - Less maturity and adoption compared to Nuxt.js even within Vue.js community - GraphQL learning cost and configuration complexity for small sites - Declining development activity as of 2024 and concerns about future prospects - Fewer rich learning resources and enterprise adoption cases compared to React/Gatsby - Build time and memory usage challenges for large-scale sites ## Reference Pages - [Gridsome Official Website](https://gridsome.org/) - [Gridsome Documentation](https://gridsome.org/docs/) - [Gridsome GitHub Repository](https://github.com/gridsome/gridsome) ## Code Examples ### Installation and Project Setup ```bash # Install Gridsome CLI npm install --global @gridsome/cli # Create project gridsome create my-gridsome-site # Navigate to project directory cd my-gridsome-site # Start development server gridsome develop # Production build gridsome build # Check build results gridsome serve # Create project from template gridsome create my-blog https://github.com/gridsome/gridsome-starter-blog.git # Check CLI version gridsome --version ``` ### Basic Configuration (gridsome.config.js) ```javascript // gridsome.config.js module.exports = { siteName: 'Gridsome', siteDescription: 'A Vue.js & GraphQL powered static site generator', siteUrl: 'https://www.example.com', plugins: [ // Add Markdown files as data source { use: '@gridsome/source-filesystem', options: { typeName: 'Post', path: 'content/posts/**/*.md', refs: { tags: { typeName: 'Tag', create: true }, author: { typeName: 'Author', create: true } } } }, // WordPress integration { use: '@gridsome/source-wordpress', options: { baseUrl: 'https://your-wordpress-site.com', typeName: 'WordPress', perPage: 100, concurrent: 10 } }, // Contentful CMS integration { use: '@gridsome/source-contentful', options: { space: 'your-space-id', accessToken: 'your-access-token', host: 'cdn.contentful.com', environment: 'master', typeName: 'Contentful' } }, // Strapi CMS integration { use: '@gridsome/source-strapi', options: { apiURL: 'http://localhost:1337', queryLimit: 1000, contentTypes: ['post', 'tag', 'author'], typeName: 'Strapi' } }, // Google Analytics { use: '@gridsome/plugin-google-analytics', options: { id: 'UA-XXXXXXXXX-X' } }, // Sitemap generation { use: '@gridsome/plugin-sitemap', options: { cacheTime: 600000, exclude: ['/exclude-me'], config: { '/*': { changefreq: 'weekly', priority: 0.5 }, '/about': { changefreq: 'monthly', priority: 0.7 } } } }, // PWA functionality { use: 'gridsome-plugin-pwa', options: { title: 'Gridsome', startUrl: '/', display: 'standalone', statusBarStyle: 'default', manifestPath: 'manifest.json', disableServiceWorker: false, serviceWorkerPath: 'service-worker.js', cachedFileTypes: 'js,json,css,html,png,jpg,jpeg,svg', shortName: 'Gridsome', themeColor: '#667eea', backgroundColor: '#ffffff', icon: 'src/favicon.png', msTileImage: '', msTileColor: '#667eea' } } ], // Use Sass/SCSS css: { loaderOptions: { scss: { additionalData: `@import "~/assets/scss/globals.scss";` } } }, // Custom webpack configuration configureWebpack: { resolve: { alias: { '@': __dirname + '/src' } } }, // Build configuration outputDir: 'dist', pathPrefix: '', // PWA configuration icon: { favicon: './src/favicon.png', touchicon: './src/favicon.png' }, // Transformer configuration transformers: { remark: { externalLinksTarget: '_blank', externalLinksRel: ['nofollow', 'noopener', 'noreferrer'], anchorClassName: 'icon icon-link', plugins: [ '@gridsome/remark-prismjs' ] } } } ``` ### Page and Component Creation ```vue <!-- src/pages/Index.vue --> <template> <Layout> <h1>Welcome to Gridsome!</h1> <p>This is our homepage built with Vue.js and GraphQL.</p> <!-- Display latest posts --> <div class="posts"> <h2>Latest Posts</h2> <div class="post-grid"> <article v-for="edge in $page.posts.edges" :key="edge.node.id" class="post-card"> <g-link :to="edge.node.path" class="post-link"> <g-image v-if="edge.node.featuredImage" :src="edge.node.featuredImage" :alt="edge.node.title" class="post-image" /> <div class="post-content"> <h3>{{ edge.node.title }}</h3> <p>{{ edge.node.excerpt }}</p> <div class="post-meta"> <time :datetime="edge.node.date">{{ formatDate(edge.node.date) }}</time> <span v-if="edge.node.author"> by {{ edge.node.author }}</span> </div> <div class="post-tags"> <span v-for="tag in edge.node.tags" :key="tag.id" class="tag"> {{ tag.title }} </span> </div> </div> </g-link> </article> </div> </div> <!-- Featured section --> <section class="featured"> <h2>Featured Content</h2> <div class="featured-grid"> <div class="featured-item"> <h3>Vue.js Guide</h3> <p>Complete guide to building modern web applications with Vue.js</p> <g-link to="/guides/vue/" class="btn">Learn More</g-link> </div> <div class="featured-item"> <h3>GraphQL Tutorial</h3> <p>Master GraphQL data querying with practical examples</p> <g-link to="/tutorials/graphql/" class="btn">Get Started</g-link> </div> </div> </section> </Layout> </template> <page-query> query { posts: allPost(limit: 6, sortBy: "date", order: DESC) { edges { node { id title excerpt date (format: "YYYY-MM-DD") path author featuredImage tags { id title } } } } } </page-query> <script> export default { metaInfo: { title: 'Home', titleTemplate: '%s | My Gridsome Site' }, methods: { formatDate(date) { return new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) } } } </script> <style scoped> .posts { margin: 3rem 0; } .post-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin-top: 2rem; } .post-card { border: 1px solid #e1e5e9; border-radius: 8px; overflow: hidden; transition: transform 0.2s, box-shadow 0.2s; } .post-card:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); } .post-link { text-decoration: none; color: inherit; display: block; } .post-image { width: 100%; height: 200px; object-fit: cover; } .post-content { padding: 1.5rem; } .post-meta { font-size: 0.9rem; color: #666; margin: 1rem 0; } .post-tags { margin-top: 1rem; } .tag { display: inline-block; background: #f1f3f4; color: #333; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-right: 0.5rem; } .featured { margin: 4rem 0; padding: 3rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 12px; } .featured-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; margin-top: 2rem; } .featured-item { text-align: center; padding: 2rem; background: rgba(255, 255, 255, 0.1); border-radius: 8px; } .btn { display: inline-block; background: #fff; color: #333; padding: 0.75rem 1.5rem; border-radius: 6px; text-decoration: none; font-weight: 600; margin-top: 1rem; transition: transform 0.2s; } .btn:hover { transform: translateY(-2px); } </style> ``` ### GraphQL Data Retrieval and Page Generation ```vue <!-- src/templates/Post.vue --> <template> <Layout> <article class="post"> <header class="post-header"> <g-image v-if="$page.post.featuredImage" :src="$page.post.featuredImage" :alt="$page.post.title" class="featured-image" /> <div class="post-meta-header"> <h1>{{ $page.post.title }}</h1> <div class="post-meta"> <time :datetime="$page.post.date">{{ formatDate($page.post.date) }}</time> <span v-if="$page.post.author" class="author">by {{ $page.post.author }}</span> <span class="reading-time">{{ readingTime }} min read</span> </div> <div class="post-tags"> <g-link v-for="tag in $page.post.tags" :key="tag.id" :to="tag.path" class="tag" > {{ tag.title }} </g-link> </div> </div> </header> <div class="post-content" v-html="$page.post.content"></div> <footer class="post-footer"> <div class="post-navigation"> <g-link v-if="$page.previous" :to="$page.previous.path" class="nav-link prev" > ← {{ $page.previous.title }} </g-link> <g-link v-if="$page.next" :to="$page.next.path" class="nav-link next" > {{ $page.next.title }} → </g-link> </div> <!-- Related posts --> <section v-if="$page.related.edges.length" class="related-posts"> <h3>Related Posts</h3> <div class="related-grid"> <article v-for="edge in $page.related.edges" :key="edge.node.id" class="related-post"> <g-link :to="edge.node.path"> <h4>{{ edge.node.title }}</h4> <p>{{ edge.node.excerpt }}</p> </g-link> </article> </div> </section> </footer> </article> </Layout> </template> <page-query> query ($id: ID!, $previousElement: ID, $nextElement: ID) { post: post (id: $id) { title content excerpt date (format: "YYYY-MM-DD") author featuredImage tags { id title path } } previous: post (id: $previousElement) { title path } next: post (id: $nextElement) { title path } related: allPost (filter: { id: { ne: $id } }, limit: 3) { edges { node { id title excerpt path } } } } </page-query> <script> export default { computed: { readingTime() { const content = this.$page.post.content const wordsPerMinute = 200 const textLength = content.replace(/<[^>]*>/g, '').length const words = textLength / 5 // Average word length return Math.ceil(words / wordsPerMinute) } }, metaInfo() { return { title: this.$page.post.title, meta: [ { name: 'description', content: this.$page.post.excerpt }, { property: 'og:title', content: this.$page.post.title }, { property: 'og:description', content: this.$page.post.excerpt }, { property: 'og:type', content: 'article' }, { property: 'article:published_time', content: this.$page.post.date }, { property: 'article:author', content: this.$page.post.author } ] } }, methods: { formatDate(date) { return new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) } } } </script> <style scoped> .post { max-width: 800px; margin: 0 auto; padding: 2rem; } .post-header { margin-bottom: 3rem; } .featured-image { width: 100%; height: 400px; object-fit: cover; border-radius: 8px; margin-bottom: 2rem; } .post-meta-header h1 { font-size: 2.5rem; line-height: 1.2; margin-bottom: 1rem; } .post-meta { color: #666; margin-bottom: 1rem; } .post-meta > * { margin-right: 1rem; } .post-tags { margin-bottom: 2rem; } .tag { display: inline-block; background: #e3f2fd; color: #1976d2; padding: 0.25rem 0.75rem; border-radius: 20px; text-decoration: none; font-size: 0.9rem; margin-right: 0.5rem; margin-bottom: 0.5rem; transition: background-color 0.2s; } .tag:hover { background: #bbdefb; } .post-content { line-height: 1.8; font-size: 1.1rem; } .post-content >>> h2 { margin-top: 3rem; margin-bottom: 1rem; color: #333; } .post-content >>> h3 { margin-top: 2rem; margin-bottom: 1rem; color: #555; } .post-content >>> p { margin-bottom: 1.5rem; } .post-content >>> pre { background: #f5f5f5; padding: 1.5rem; border-radius: 8px; overflow-x: auto; margin: 2rem 0; } .post-content >>> blockquote { border-left: 4px solid #ddd; padding-left: 1rem; margin: 2rem 0; font-style: italic; color: #666; } .post-footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid #eee; } .post-navigation { display: flex; justify-content: space-between; margin-bottom: 3rem; } .nav-link { text-decoration: none; color: #1976d2; font-weight: 600; padding: 0.75rem 1.5rem; border: 2px solid #1976d2; border-radius: 6px; transition: all 0.2s; } .nav-link:hover { background: #1976d2; color: white; } .related-posts { margin-top: 3rem; } .related-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin-top: 1.5rem; } .related-post { padding: 1.5rem; border: 1px solid #eee; border-radius: 8px; transition: transform 0.2s; } .related-post:hover { transform: translateY(-2px); } .related-post a { text-decoration: none; color: inherit; } .related-post h4 { margin-bottom: 0.5rem; color: #333; } </style> ``` ### Layout and Navigation ```vue <!-- src/layouts/Default.vue --> <template> <div class="layout"> <header class="header"> <div class="container"> <nav class="nav"> <g-link to="/" class="nav-logo"> <g-image src="~/assets/images/logo.png" alt="Logo" /> <span>My Gridsome Site</span> </g-link> <ul class="nav-menu"> <li><g-link to="/" class="nav-link">Home</g-link></li> <li><g-link to="/blog/" class="nav-link">Blog</g-link></li> <li><g-link to="/about/" class="nav-link">About</g-link></li> <li><g-link to="/contact/" class="nav-link">Contact</g-link></li> </ul> <!-- Mobile menu toggle --> <button class="nav-toggle" @click="toggleMobileMenu" :class="{ active: mobileMenuOpen }" > <span></span> <span></span> <span></span> </button> </nav> <!-- Mobile menu --> <div class="mobile-menu" :class="{ open: mobileMenuOpen }"> <ul class="mobile-nav"> <li><g-link to="/" @click="closeMobileMenu">Home</g-link></li> <li><g-link to="/blog/" @click="closeMobileMenu">Blog</g-link></li> <li><g-link to="/about/" @click="closeMobileMenu">About</g-link></li> <li><g-link to="/contact/" @click="closeMobileMenu">Contact</g-link></li> </ul> </div> </div> </header> <main class="main"> <slot/> </main> <footer class="footer"> <div class="container"> <div class="footer-content"> <div class="footer-section"> <h3>About</h3> <p>Building amazing websites with Vue.js and GraphQL using Gridsome.</p> </div> <div class="footer-section"> <h3>Links</h3> <ul> <li><g-link to="/blog/">Blog</g-link></li> <li><g-link to="/about/">About</g-link></li> <li><g-link to="/contact/">Contact</g-link></li> <li><g-link to="/privacy/">Privacy Policy</g-link></li> </ul> </div> <div class="footer-section"> <h3>Social</h3> <div class="social-links"> <a href="https://twitter.com/yourusername" target="_blank" rel="noopener">Twitter</a> <a href="https://github.com/yourusername" target="_blank" rel="noopener">GitHub</a> <a href="https://linkedin.com/in/yourusername" target="_blank" rel="noopener">LinkedIn</a> </div> </div> </div> <div class="footer-bottom"> <p>&copy; {{ currentYear }} My Gridsome Site. All rights reserved.</p> </div> </div> </footer> </div> </template> <script> export default { data() { return { mobileMenuOpen: false } }, computed: { currentYear() { return new Date().getFullYear() } }, methods: { toggleMobileMenu() { this.mobileMenuOpen = !this.mobileMenuOpen }, closeMobileMenu() { this.mobileMenuOpen = false } } } </script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; } .container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; } .layout { min-height: 100vh; display: flex; flex-direction: column; } .header { background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.1); position: sticky; top: 0; z-index: 100; } .nav { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; } .nav-logo { display: flex; align-items: center; text-decoration: none; font-size: 1.25rem; font-weight: bold; color: #333; } .nav-logo img { width: 40px; height: 40px; margin-right: 0.5rem; } .nav-menu { display: flex; list-style: none; gap: 2rem; } .nav-link { text-decoration: none; color: #333; font-weight: 500; transition: color 0.2s; } .nav-link:hover, .nav-link.active--exact { color: #667eea; } .nav-toggle { display: none; flex-direction: column; background: none; border: none; cursor: pointer; padding: 0.25rem; } .nav-toggle span { width: 25px; height: 3px; background: #333; margin: 3px 0; transition: 0.3s; } .mobile-menu { display: none; position: absolute; top: 100%; left: 0; right: 0; background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .mobile-nav { list-style: none; padding: 1rem; } .mobile-nav li { margin-bottom: 1rem; } .mobile-nav a { text-decoration: none; color: #333; font-size: 1.1rem; } .main { flex: 1; padding: 2rem 0; } .footer { background: #f8f9fa; margin-top: 4rem; } .footer-content { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; padding: 3rem 0; } .footer-section h3 { margin-bottom: 1rem; color: #333; } .footer-section ul { list-style: none; } .footer-section ul li { margin-bottom: 0.5rem; } .footer-section a { color: #666; text-decoration: none; transition: color 0.2s; } .footer-section a:hover { color: #667eea; } .social-links { display: flex; gap: 1rem; } .footer-bottom { border-top: 1px solid #dee2e6; padding: 1.5rem 0; text-align: center; color: #666; } /* Responsive design */ @media (max-width: 768px) { .nav-menu { display: none; } .nav-toggle { display: flex; } .mobile-menu.open { display: block; } .nav-toggle.active span:nth-child(1) { transform: rotate(-45deg) translate(-5px, 6px); } .nav-toggle.active span:nth-child(2) { opacity: 0; } .nav-toggle.active span:nth-child(3) { transform: rotate(45deg) translate(-5px, -6px); } } </style> ``` ### Data Source Integration and Dynamic Page Generation ```javascript // gridsome.server.js const axios = require('axios') module.exports = function (api) { // Data loading during server API initialization api.loadSource(async actions => { // Fetch data from external API const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts') // Create GraphQL collection const collection = actions.addCollection({ typeName: 'ExternalPost' }) // Add data to collection for (const post of data) { collection.addNode({ id: post.id, title: post.title, content: post.body, userId: post.userId }) } // Custom schema definition actions.addSchemaTypes(` type Post implements Node @infer { id: ID! title: String! content: String! excerpt: String featuredImage: Image tags: [Tag] @reference(by: "title", to: "title") author: Author @reference(by: "title", to: "title") } type Tag implements Node @infer { id: ID! title: String! posts: [Post] @reference(by: "title", to: "tags") } type Author implements Node @infer { id: ID! title: String! bio: String avatar: Image posts: [Post] @reference(by: "title", to: "author") } `) }) // Dynamic page creation api.createPages(({ createPage }) => { // Create yearly archive pages createPage({ path: '/archive/:year', component: './src/templates/Archive.vue' }) // Create tag pages createPage({ path: '/tag/:id', component: './src/templates/Tag.vue' }) // Create author pages createPage({ path: '/author/:id', component: './src/templates/Author.vue' }) // Create custom pages createPage({ path: '/custom/:slug', component: './src/templates/Custom.vue' }) }) // Pre-build hook api.beforeBuild(({ config, store }) => { console.log('Starting build...') // Configuration based on environment variables if (process.env.NODE_ENV === 'production') { // Additional configuration for production environment config.runtimeCompiler = false config.outputDir = 'dist' } }) // Post-build hook api.afterBuild(({ config }) => { console.log('Build complete!') // Additional post-processing // - Sitemap generation // - RSS feed creation // - External service notifications }) } ``` ### SEO Optimization and Performance ```vue <!-- src/components/SEO.vue --> <template> <div> <!-- This component is not displayed --> </div> </template> <script> export default { props: { title: String, description: String, image: String, article: Boolean, author: String, publishedTime: String, modifiedTime: String }, metaInfo() { const meta = [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { name: 'description', content: this.description }, { name: 'robots', content: 'index,follow' }, // Open Graph { property: 'og:type', content: this.article ? 'article' : 'website' }, { property: 'og:title', content: this.title }, { property: 'og:description', content: this.description }, { property: 'og:url', content: process.env.GRIDSOME_BASE_URL + this.$route.path }, { property: 'og:site_name', content: 'My Gridsome Site' }, // Twitter Card { name: 'twitter:card', content: 'summary_large_image' }, { name: 'twitter:title', content: this.title }, { name: 'twitter:description', content: this.description }, { name: 'twitter:site', content: '@yourusername' }, { name: 'twitter:creator', content: '@yourusername' } ] // Image meta tags if (this.image) { meta.push( { property: 'og:image', content: this.image }, { property: 'og:image:width', content: '1200' }, { property: 'og:image:height', content: '630' }, { name: 'twitter:image', content: this.image } ) } // Article-specific meta tags if (this.article) { if (this.author) { meta.push({ property: 'article:author', content: this.author }) } if (this.publishedTime) { meta.push({ property: 'article:published_time', content: this.publishedTime }) } if (this.modifiedTime) { meta.push({ property: 'article:modified_time', content: this.modifiedTime }) } } return { title: this.title, meta, link: [ { rel: 'canonical', href: process.env.GRIDSOME_BASE_URL + this.$route.path } ] } } } </script> <!-- src/components/ImageOptimizer.vue --> <template> <div class="image-wrapper"> <g-image :src="src" :alt="alt" :width="width" :height="height" :quality="quality" :fit="fit" :position="position" :blur="blur" loading="lazy" class="optimized-image" @load="onImageLoad" @error="onImageError" /> <div v-if="showCaption && caption" class="image-caption"> {{ caption }} </div> </div> </template> <script> export default { props: { src: { type: String, required: true }, alt: { type: String, required: true }, width: { type: Number, default: 800 }, height: { type: Number, default: null }, quality: { type: Number, default: 80 }, fit: { type: String, default: 'cover' }, position: { type: String, default: 'center' }, blur: { type: Number, default: 0 }, caption: { type: String, default: '' }, showCaption: { type: Boolean, default: false } }, methods: { onImageLoad() { this.$emit('load') }, onImageError() { this.$emit('error') console.error('Failed to load image:', this.src) } } } </script> <style scoped> .image-wrapper { position: relative; display: inline-block; } .optimized-image { width: 100%; height: auto; border-radius: 8px; transition: transform 0.3s ease; } .optimized-image:hover { transform: scale(1.02); } .image-caption { margin-top: 0.5rem; font-size: 0.9rem; color: #666; text-align: center; font-style: italic; } </style> ``` ### Build and Deployment ```json { "name": "my-gridsome-site", "version": "1.0.0", "private": true, "scripts": { "build": "gridsome build", "develop": "gridsome develop", "explore": "gridsome explore", "serve": "gridsome serve", "clean": "gridsome clean", "deploy": "npm run build && npm run deploy:netlify", "deploy:netlify": "netlify deploy --prod --dir=dist", "deploy:vercel": "vercel --prod", "lint": "eslint --ext .js,.vue src/", "lint:fix": "eslint --ext .js,.vue src/ --fix" }, "dependencies": { "@gridsome/plugin-google-analytics": "^0.1.2", "@gridsome/plugin-sitemap": "^0.4.0", "@gridsome/source-contentful": "^0.2.4", "@gridsome/source-filesystem": "^0.6.2", "@gridsome/source-strapi": "^0.2.0", "@gridsome/source-wordpress": "^0.3.4", "@gridsome/transformer-remark": "^0.6.4", "@gridsome/vue-remark": "^0.2.6", "gridsome": "^0.7.23", "gridsome-plugin-pwa": "^0.0.19", "vue": "^2.6.14", "vue-meta": "^2.4.0" }, "devDependencies": { "@gridsome/cli": "^0.3.4", "eslint": "^8.0.0", "eslint-plugin-vue": "^8.0.0", "sass": "^1.49.0", "sass-loader": "^12.0.0" } } ``` ```yaml # .github/workflows/deploy.yml name: Build and Deploy on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Build run: npm run build env: GRIDSOME_BASE_URL: https://your-site.com - name: Deploy to Netlify if: github.ref == 'refs/heads/main' uses: nwtgck/[email protected] with: publish-dir: './dist' production-branch: main github-token: ${{ secrets.GITHUB_TOKEN }} deploy-message: "Deploy from GitHub Actions" env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} ``` ```javascript // netlify.toml [build] command = "npm run build" publish = "dist/" [build.environment] NODE_VERSION = "18" GRIDSOME_BASE_URL = "https://your-site.netlify.app" [[headers]] for = "/*" [headers.values] X-Frame-Options = "DENY" X-XSS-Protection = "1; mode=block" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" [[headers]] for = "/static/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable" [[redirects]] from = "/old-page" to = "/new-page" status = 301 # API proxy configuration [[redirects]] from = "/api/*" to = "https://your-api.com/api/:splat" status = 200 ```