Gridsome
Vue.js + GraphQL based SSG. Developed as Vue.js version of Gatsby with over 8k GitHub stars.
トレンド・動向
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>© {{ 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
```