Overview

Why This Matters: Before a prospective student ever sees a campus tour or talks to an advisor, they encounter a university through search results – a title, a short description, and how well-organized the page looks when they click through. Title tags, meta descriptions, and heading structure aren’t just technical HTML details; they directly shape whether a school shows up clearly in search results, whether students click through at all, and how easily they can find what they’re looking for once they land on the page. A school with a truncated, confusing title or no description at all is effectively less visible to prospective students than a competitor with a clean, well-structured page – regardless of how strong the actual academic programs are. Understanding these structural signals is a first step toward understanding whether CSU campuses are presenting themselves as effectively as possible in the channel where most students begin their college search.

Business Question: How do CSU campuses compare in website structure and SEO signals – specifically title tags, meta descriptions, and heading organization (H1-H3)?

Scope: All 23 CSU campuses, compared against each other, using each campus’s homepage.

Method: Homepage title tag, meta description, and H1–H3 headings are scraped for each campus using rvest, then enriched with structural metrics (title/meta character length, heading counts) to compare structural SEO quality across the CSU system.

A note on SEO thresholds used in this analysis: According to Google Search Central’s own documentation, there is no strict character limit on title tags or meta descriptions – text is truncated in search results as needed, rather than penalized outright (Google Search Central, “Influencing Title Links in Google Search”; “How to Write Meta Descriptions”). The ~60-character title and ~160-character meta description thresholds used throughout this analysis reflect commonly observed display limits reported by SEO practitioners (Moz, Ahrefs, Screaming Frog), not official Google ranking rules. Similarly, “one H1 per page” is a widely recommended SEO best practice (Yoast, Ahrefs) rather than a documented Google requirement – Google’s John Mueller has stated that multiple H1 tags are not penalized by Google’s algorithm. We use these thresholds because they reflect real-world display behavior in search results, which affects click-through rate even when it doesn’t affect ranking directly.

library(rvest)
library(stringr)
library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(purrr)
library(ggplot2)
library(tidyr)
library(tidytext)
# All 23 CSU campuses -- URL paired with official/common school name, so charts can display real names instead of URL fragments.

csu_schools <- tibble::tribble(
  ~school,                       ~url,
  "Cal State Bakersfield",       "https://www.csub.edu",
  "CSU Channel Islands",         "https://www.csuci.edu",
  "Chico State",                 "https://www.csuchico.edu",
  "CSU Dominguez Hills",         "https://www.csudh.edu",
  "Cal State East Bay",          "https://www.csueastbay.edu",
  "Fresno State",                "https://www.fresnostate.edu",
  "Cal State Fullerton",         "https://www.fullerton.edu",
  "Cal Poly Humboldt",           "https://www.humboldt.edu",
  "Cal State Long Beach",        "https://www.csulb.edu",
  "Cal State LA",                "https://www.calstatela.edu",
  "Cal Maritime",                "https://www.csum.edu",
  "CSU Monterey Bay",            "https://csumb.edu",
  "CSU Northridge",              "https://www.csun.edu",
  "Cal Poly Pomona",             "https://www.cpp.edu",
  "Sacramento State",            "https://www.csus.edu",
  "Cal State San Bernardino",    "https://www.csusb.edu",
  "San Diego State",             "https://www.sdsu.edu",
  "San Francisco State",         "https://www.sfsu.edu",
  "San Jose State",              "https://www.sjsu.edu",
  "Cal Poly SLO",                "https://www.calpoly.edu",
  "CSU San Marcos",              "https://www.csusm.edu",
  "Sonoma State",                "https://www.sonoma.edu",
  "Stanislaus State",            "https://www.csustan.edu"
)

csu_urls <- csu_schools$url

Data Collection

Scraping Function

The function below scrapes one university homepage at a time: it reads the page directly, then pulls out the title tag, meta description, and H1–H3 headings. A tryCatch safety net is added since at 23 real university sites, one failed school could otherwise stop the whole loop and lose all other results.

scrape_seo <- function(url) {

  Sys.sleep(1.5) # polite delay between requests

  page <- tryCatch(read_html(url), error = function(e) NULL)

  if (is.null(page)) {
    return(tibble(
      url = url,
      title = NA, title_length = NA,
      meta_description = NA, meta_length = NA,
      h1 = NA, h1_count = NA,
      h2 = NA, h2_count = NA,
      h3 = NA, h3_count = NA,
      status = "failed"
    ))
  }

  # Title tag. Length matters -- SEO practitioners commonly note truncation risk past ~60 characters, though Google itself sets no strict limit.
  title_tag <- page |> html_element("title") |> html_text2() |> str_squish()
  title_len <- str_length(title_tag)

  # Meta description. Text lives in content="" attribute, so we use html_attr() instead of html_text2(). Commonly truncated past ~160 characters in search results, per SEO practitioner sources (not an official Google limit).
  meta_desc <- page |> html_element("meta[name='description']") |>
    html_attr("content") |> str_squish()
  meta_len <- str_length(meta_desc)

  # H1-H3 headings. html_elements() (plural) grabs ALL tags at each level, not just the first -- "exactly one h1" is an SEO best-practice convention, so h1_count flags schools with 0 or multiple as a potential structural note.
  h1_tags  <- page |> html_elements("h1") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h1_count <- page |> html_elements("h1") |> length()

  h2_tags  <- page |> html_elements("h2") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h2_count <- page |> html_elements("h2") |> length()

  h3_tags  <- page |> html_elements("h3") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h3_count <- page |> html_elements("h3") |> length()

  tibble(
    url = url,
    title = title_tag, title_length = title_len,
    meta_description = meta_desc, meta_length = meta_len,
    h1 = h1_tags, h1_count = h1_count,
    h2 = h2_tags, h2_count = h2_count,
    h3 = h3_tags, h3_count = h3_count,
    status = "ok"
  )
}

Running the Scraper Across All 23 CSU Campuses

# Alternative approach using a for loop (commented out for reference):
# results <- list()
# for (i in seq_along(csu_urls)) {
#   results[[i]] <- scrape_seo(csu_urls[i])
# }
# seo_data <- bind_rows(results)

# We use purrr::map() instead of a for loop because it keeps the scraping
# logic self-contained in one function (scrape_seo), makes it easy to test
# a single school on its own, and is the more standard tidyverse approach
# for applying a function across a list of inputs. Both produce the same result.
results <- map(csu_urls, scrape_seo)
seo_data <- bind_rows(results)

# Join in the readable school names from our lookup table, matched on url
seo_data <- seo_data |> left_join(csu_schools, by = "url")

seo_data
## # A tibble: 23 × 13
##    url      title title_length meta_description meta_length h1    h1_count h2   
##    <chr>    <chr>        <int> <chr>                  <int> <chr>    <int> <chr>
##  1 https:/… Home…           47 <NA>                      NA "CSU…        1 Alum…
##  2 https:/… CSU …           19 Welcome to Cali…         159 "Wel…        1 Appl…
##  3 https:/… Chic…           11 <NA>                      NA "Chi…        1 Your…
##  4 https:/… Cali…           43 Academics are t…         497 ""           0 Cali…
##  5 https:/… Cali…           45 Cal State East …         154 "Cal…        1 An E…
##  6 https:/… Home…           19 <NA>                      NA "We …        1 Quic…
##  7 https:/… Achi…           57 Launch your car…         143 "Cal…        1 Visi…
##  8 https:/… Cal …           69 Explore Cal Pol…         144 "Fin…        1 Quic…
##  9 https:/… Cali…           39 CSULB is a larg…         102 "CSU…        1 Util…
## 10 https:/… Home…           19 <NA>                      NA "Cal…        1 Fami…
## # ℹ 13 more rows
## # ℹ 5 more variables: h2_count <int>, h3 <chr>, h3_count <int>, status <chr>,
## #   school <chr>
seo_data |> filter(status == "failed") |> select(school, url)
## # A tibble: 1 × 2
##   school       url                   
##   <chr>        <chr>                 
## 1 Sonoma State https://www.sonoma.edu
write.csv(seo_data, file = "csu_seo_data.csv", row.names = FALSE)

Exploratory Data Analysis

This section examines website structure and SEO signals across the 22 successfully-scraped CSU campuses (Sonoma State failed to scrape and is excluded from confirmed counts – see Data Collection section). Enrollment and demographic correlation analysis will follow once merged data from the group’s survey/enrollment work is finalized.

theme_japandi <- theme_minimal(base_size = 12) +
  theme(
    panel.grid.minor = element_blank(),
    panel.grid.major.y = element_blank(),
    plot.background = element_rect(fill = "#FAF7F2", color = NA),
    panel.background = element_rect(fill = "#FAF7F2", color = NA),
    text = element_text(color = "#5C5346"),
    plot.title = element_text(face = "bold", size = 14),
    axis.title = element_text(size = 10)
  )

Title Tags

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = reorder(school, title_length), y = title_length)) +
  geom_col(fill = "#A3B18A") +
  geom_hline(yintercept = 60, linetype = "dashed", color = "#BC6C25") +
  coord_flip() +
  labs(title = "Title Tag Length by CSU Campus",
       subtitle = "Dashed line = ~60 character display threshold",
       x = NULL, y = "Characters") +
  theme_japandi

Cal Poly Pomona (79), CSU San Marcos (72), and Cal Poly Humboldt (69) exceed the ~60-character display threshold commonly cited by SEO practitioners, while CSU Channel Islands’ title (19 characters) is well within range but on the shorter end overall.

Meta Descriptions

8 of 22 successfully-scraped CSU campuses (36%) have no meta description.

Missing

  • Cal State Bakersfield

  • Chico State

  • Fresno State

  • Cal State LA

  • Cal Maritime

  • San Diego State

  • San Jose State

  • CSU San Marcos

  • Sonoma State (scrape failed – status unconfirmed, not counted above)

Present

  • CSU Channel Islands
  • CSU Dominguez Hills
  • Cal State East Bay
  • Cal State Fullerton
  • Cal Poly Humboldt
  • Cal State Long Beach
  • CSU Monterey Bay
  • CSU Northridge
  • Cal Poly Pomona
  • Sacramento State
  • Cal State San Bernardino
  • San Francisco State
  • Cal Poly SLO
  • Stanislaus State

CSU Channel Islands has a meta description present, at 159 characters – just under the ~160-character display threshold commonly cited by SEO practitioners.

Heading Organization

seo_data |>
  filter(status == "ok") |>
  mutate(h1_status = ifelse(h1_count == 1, "Follows Convention", "Deviates")) |>
  ggplot(aes(x = reorder(school, h1_count), y = h1_count, color = h1_status)) +
  geom_segment(aes(xend = school, y = 0, yend = h1_count), linewidth = 0.6) +
  geom_point(size = 3.5) +
  coord_flip() +
  scale_color_manual(values = c("Follows Convention" = "#A3B18A", "Deviates" = "#BC6C25")) +
  labs(title = "H1 Tag Count by CSU Campus",
       subtitle = "SEO best-practice convention: exactly one h1 per page",
       x = NULL, y = "H1 Count", color = NULL) +
  theme_japandi

Most campuses follow the one-h1 convention, but four deviate: CSU Dominguez Hills (0), Cal Maritime (2), Stanislaus State (4), and Cal Poly Pomona (6). CSU Channel Islands follows the convention with exactly one h1.

seo_data |>
  filter(status == "ok") |>
  select(school, h1_count, h2_count, h3_count) |>
  pivot_longer(cols = h1_count:h3_count, names_to = "heading", values_to = "count") |>
  mutate(heading = recode(heading, h1_count = "h1", h2_count = "h2", h3_count = "h3")) |>
  ggplot(aes(x = school, y = count, fill = heading)) +
  geom_col(position = "dodge") +
  coord_flip() +
  scale_fill_manual(values = c("h1" = "#6B8CAE", "h2" = "#A3B18A", "h3" = "#DDA15E")) +
  labs(title = "Heading Structure by CSU Campus", x = NULL, y = "Tag Count", fill = NULL) +
  theme_japandi

Heading volume varies widely – Cal Poly Humboldt leads with 23 h2 tags while CSU San Marcos has just 4. CSU Channel Islands sits mid-pack with 16 h2 tags and no gaps across h1-h3, unlike five campuses that skip h3 entirely.

Additional Analyses

This section explores three additional angles on the scraped SEO data: a composite structural score to rank schools overall, a word frequency analysis of heading text to explore tone/messaging differences (tying back to the group’s original business question on tone across CSU campuses), and a correlation check between structural metrics.

1. Composite SEO Score

Each school is scored 0-4 based on whether it passes four structural checks (title length, meta description, single h1, complete heading hierarchy). This gives a single rankable measure of overall structural strength, rather than four separate metrics that can pull in different directions.

seo_data <- seo_data |>
  mutate(
    pass_title = title_length >= 10 & title_length <= 60,
    pass_meta = !is.na(meta_description) & meta_description != "NA" &
                meta_length >= 50 & meta_length <= 160,
    pass_h1 = h1_count == 1,
    pass_hierarchy = h2_count > 0 & h3_count > 0,
    seo_score = rowSums(across(c(pass_title, pass_meta, pass_h1, pass_hierarchy)),
                         na.rm = TRUE)
  )

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = reorder(school, seo_score), y = seo_score, fill = factor(seo_score))) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c("0" = "#BC6C25", "1" = "#DDA15E", "2" = "#E9C46A",
                                "3" = "#A3B18A", "4" = "#6B8CAE")) +
  labs(title = "Composite SEO Score by CSU Campus",
       subtitle = "Title length, meta description, h1 count, heading hierarchy (0-4)",
       x = NULL, y = "Score", fill = "Score") +
  theme_japandi

Five schools scored a perfect 4/4 – CSU Northridge, CSU Channel Islands, Cal State Long Beach, Cal State Fullerton, and Cal State East Bay – meeting all four structural checks (title length, meta description, single h1, complete heading hierarchy). Most schools (13 of 22) scored 3/4, missing just one check. Three schools scored lowest: CSU San Marcos, CSU Dominguez Hills, and Cal Maritime (1/4 each), each failing on multiple fronts rather than one isolated issue. CSU Channel Islands’ top score confirms it as one of the stronger performers structurally, not just “acceptable.”

2. Heading Word Frequency Analysis

This examines the actual words used across all h1/h2/h3 headings, to see whether schools cluster around different messaging themes (e.g., action-oriented like “Explore” and “Apply” vs. prestige-oriented like “Excellence” and “Ranked”). This ties directly to the group’s original business question on tone differences among Cal States.

# Words to exclude: school-name fragments (add noise, not tone), and
# common site-navigation chrome that isn't part of actual page content
nav_noise <- c("csu", "cal", "state", "university", "menu", "footer",
               "navigation", "skip", "toggle", "search", "jul", "content", "main")

heading_words <- seo_data |>
  filter(status == "ok") |>
  select(school, h1, h2, h3) |>
  pivot_longer(cols = h1:h3, names_to = "heading_level", values_to = "text") |>
  filter(!is.na(text), text != "") |>
  unnest_tokens(word, text) |>
  anti_join(stop_words, by = "word") |>
  filter(!word %in% nav_noise,
         !str_detect(word, "^[0-9]+$"))   # drops any remaining pure numbers

top_words <- heading_words |>
  count(word, sort = TRUE) |>
  slice_head(n = 20)

ggplot(top_words, aes(x = reorder(word, n), y = n)) +
  geom_col(fill = "#A3B18A") +
  coord_flip() +
  labs(title = "Most Common Words in CSU Homepage Headings",
       subtitle = "Across all H1-H3 tags, all 22 schools (navigation chrome excluded)",
       x = NULL, y = "Frequency") +
  theme_japandi

# Optional deeper look: top 3 words per school, to compare individual tone
heading_words |>
  count(school, word, sort = TRUE) |>
  group_by(school) |>
  slice_max(n, n = 3, with_ties = FALSE) |>
  ungroup() |>
  arrange(school, desc(n))
## # A tibble: 66 × 3
##    school              word            n
##    <chr>               <chr>       <int>
##  1 CSU Channel Islands information     4
##  2 CSU Channel Islands campus          2
##  3 CSU Channel Islands advocate        1
##  4 CSU Dominguez Hills california      1
##  5 CSU Dominguez Hills dominguez       1
##  6 CSU Dominguez Hills events          1
##  7 CSU Monterey Bay    education       3
##  8 CSU Monterey Bay    improve         2
##  9 CSU Monterey Bay    life            2
## 10 CSU Northridge      csun            5
## # ℹ 56 more rows

After removing navigation chrome and school-name fragments, “campus,” “explore,” “news,” and “events” are the most common words across CSU homepage headings – suggesting a shared tone across the system that’s oriented around campus life and current happenings rather than academic prestige or rankings language. No strong “action vs. prestige” split emerged at the system-wide level; that distinction may be more visible in the per-school breakdown above than in the aggregate chart.

3. Correlation Check: Do Structural Metrics Move Together?

A simple check for whether schools that write longer titles also write longer meta descriptions, and whether schools with more h2 tags also tend to have more h3 tags (deeper nesting vs. flat structure).

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = title_length, y = meta_length)) +
  geom_point(color = "#A3B18A", size = 3) +
  geom_smooth(method = "lm", se = FALSE, color = "#BC6C25", linewidth = 0.7) +
  labs(title = "Title Length vs. Meta Description Length",
       x = "Title Length (characters)", y = "Meta Description Length (characters)") +
  theme_japandi
## `geom_smooth()` using formula = 'y ~ x'
## Warning: Removed 8 rows containing non-finite outside the scale range
## (`stat_smooth()`).
## Warning: Removed 8 rows containing missing values or values outside the scale range
## (`geom_point()`).

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = h2_count, y = h3_count)) +
  geom_point(color = "#6B8CAE", size = 3) +
  geom_smooth(method = "lm", se = FALSE, color = "#BC6C25", linewidth = 0.7) +
  labs(title = "H2 Count vs. H3 Count",
       subtitle = "Do schools with more subheadings also nest deeper?",
       x = "H2 Count", y = "H3 Count") +
  theme_japandi
## `geom_smooth()` using formula = 'y ~ x'

seo_data |>
  filter(status == "ok") |>
  summarise(
    title_meta_cor = cor(title_length, meta_length, use = "complete.obs"),
    h2_h3_cor = cor(h2_count, h3_count, use = "complete.obs")
  )
## # A tibble: 1 × 2
##   title_meta_cor h2_h3_cor
##            <dbl>     <dbl>
## 1          0.105     0.579

Title length and meta description length show a weak positive relationship (r = 0.105) – schools that write longer titles don’t meaningfully tend to write longer meta descriptions; these appear to be largely independent editorial choices. H2 and h3 counts show a moderate positive relationship (r = 0.579) – schools with more subheadings do tend to nest more deeply, suggesting heading hierarchy depth is a more consistent structural habit than title/meta length choices.

Results

Across the 22 successfully-scraped CSU campuses, structural SEO quality varies meaningfully but not dramatically – most schools cluster around 3-4 out of 4 on the composite score, with a small group of clear laggards (CSU San Marcos, CSU Dominguez Hills, Cal Maritime) failing multiple checks simultaneously rather than one isolated issue.

CSU Channel Islands performs strongly on every individual metric: a 19-character title well within the display threshold, a 159-character meta description just under the limit, exactly one h1 tag, and complete heading coverage (h1 through h3, no gaps) – earning a perfect 4/4 composite score, tied with four other campuses (CSUN, Long Beach, Fullerton, East Bay).

The most common structural gap system-wide is missing meta descriptions (36% of schools), not title length or heading count issues – suggesting that’s the highest-leverage, easiest fix across the CSU system if this were an actual improvement initiative.

Structural metrics don’t move in lockstep: title and meta length choices appear independent (r = 0.105), while heading depth (h2/h3) is more consistent within a school (r = 0.579) – suggesting heading structure reflects a more deliberate site-wide template choice than title/meta writing, which may vary page to page or be handled less centrally.

(Note: Sonoma State is excluded from all confirmed counts above due to a failed scrape – see Data Collection section.)

References

Primary source (official Google documentation): Google Search Central. “Influencing Title Links in Google Search.” Google for Developers. https://developers.google.com/search/docs/appearance/title-link Google Search Central. “How to Write Meta Descriptions.” Google for Developers. https://developers.google.com/search/docs/appearance/snippet

Supporting industry sources (display-length thresholds, practitioner conventions): Moz. “Title Tag.” Moz Learn SEO. https://moz.com/learn/seo/title-tag Ahrefs. “H1 Tag: What It Is, Best Practices & Examples.” https://ahrefs.com/blog/h1-tag/ Yoast. “Why You Only Need One H1 Heading Per Post or Page.” https://yoast.com/one-h1-heading-per-post/

Note: Google’s own documentation confirms there is no strict character limit on title tags or meta descriptions – text is truncated in search results as needed, rather than penalized. The ~60 and ~160 character thresholds used in this analysis reflect commonly observed display limits reported by SEO practitioners (Moz, Ahrefs, Screaming Frog), not official Google rules. Similarly, “one H1 per page” is an SEO best-practice convention rather than a documented Google ranking requirement – Google’s John Mueller has stated multiple H1s are not penalized.